diff --git a/README.md b/README.md
index 5f37de0..300fc69 100644
--- a/README.md
+++ b/README.md
@@ -77,6 +77,9 @@ composer dev-tools skills
# Merges and synchronizes .gitignore files
composer dev-tools gitignore
+# Generates a LICENSE file from composer.json license information
+composer dev-tools license
+
# Installs and synchronizes dev-tools scripts, GitHub Actions workflows,
# .editorconfig, .gitignore rules, packaged skills, and the repository wiki
# submodule in .github/wiki
diff --git a/docs/api/commands.rst b/docs/api/commands.rst
index 9226d2a..07628a7 100644
--- a/docs/api/commands.rst
+++ b/docs/api/commands.rst
@@ -49,6 +49,9 @@ resolution, configuration fallback, PSR-4 lookup, and child-command dispatch.
- ``dev-tools:sync``
- Synchronizes consumer-facing scripts, automation assets, and packaged
skills.
- * - ``FastForward\DevTools\Command\GitIgnoreCommand``
- - ``gitignore``
- - Merges and synchronizes .gitignore files.
+ * - ``FastForward\DevTools\Command\GitIgnoreCommand``
+ - ``gitignore``
+ - Merges and synchronizes .gitignore files.
+ * - ``FastForward\DevTools\Command\CopyLicenseCommand``
+ - ``license``
+ - Generates a LICENSE file from composer.json license information.
diff --git a/docs/running/specialized-commands.rst b/docs/running/specialized-commands.rst
index c2f53a3..0ba25d7 100644
--- a/docs/running/specialized-commands.rst
+++ b/docs/running/specialized-commands.rst
@@ -199,3 +199,22 @@ Important details:
- duplicates are removed and entries are sorted alphabetically;
- it uses the Reader, Merger, and Writer components from the GitIgnore
namespace.
+
+``license``
+----------
+
+Generates a LICENSE file from composer.json license information.
+
+.. code-block:: bash
+
+ composer dev-tools license
+
+Important details:
+
+- it reads the ``license`` field from ``composer.json``;
+- it supports common open-source licenses (MIT, Apache-2.0, BSD-2-Clause,
+ BSD-3-Clause, GPL-3.0, LGPL-3.0, and MPL-2.0);
+- it resolves placeholders such as ``[year]``, ``[author]``, and
+ ``[project]`` using information from ``composer.json``;
+- it uses template files from ``resources/license-templates/``;
+- it skips generation if a LICENSE file already exists.
diff --git a/resources/licenses/apache-2.0.txt b/resources/licenses/apache-2.0.txt
new file mode 100644
index 0000000..f5020b0
--- /dev/null
+++ b/resources/licenses/apache-2.0.txt
@@ -0,0 +1,39 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ 2. Grant of Copyright License.
+
+ 3. Grant of Patent License.
+
+ 4. Redistribution.
+
+ 5. Submission of Contributions.
+
+ 6. Trademarks.
+
+ 7. Disclaimer of Warranty.
+
+ 8. Limitation of Liability.
+
+ 9. Accepting Warranty or Additional Liability.
+
+ END OF TERMS AND CONDITIONS
+
+ Copyright {{ year }} {{ organization }}{{ author }}
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
\ No newline at end of file
diff --git a/resources/licenses/bsd-2-clause.txt b/resources/licenses/bsd-2-clause.txt
new file mode 100644
index 0000000..14fbac7
--- /dev/null
+++ b/resources/licenses/bsd-2-clause.txt
@@ -0,0 +1,24 @@
+BSD 2-Clause License
+
+Copyright (c) {{ year }} {{ organization }}{{ author }}
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
\ No newline at end of file
diff --git a/resources/licenses/bsd-3-clause.txt b/resources/licenses/bsd-3-clause.txt
new file mode 100644
index 0000000..fd29ef5
--- /dev/null
+++ b/resources/licenses/bsd-3-clause.txt
@@ -0,0 +1,28 @@
+BSD 3-Clause License
+
+Copyright (c) {{ year }} {{ organization }}{{ author }}
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+3. Neither the name of the copyright holder nor the names of its contributors
+ may be used to endorse or promote products derived from this software without
+ specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
\ No newline at end of file
diff --git a/resources/licenses/gpl-3.0-or-later.txt b/resources/licenses/gpl-3.0-or-later.txt
new file mode 100644
index 0000000..4e78540
--- /dev/null
+++ b/resources/licenses/gpl-3.0-or-later.txt
@@ -0,0 +1,17 @@
+GNU GENERAL PUBLIC LICENSE
+Version 3, 29 June 2007
+
+Copyright (C) {{ year }} {{ organization }}{{ author }}
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see .
\ No newline at end of file
diff --git a/resources/licenses/isc.txt b/resources/licenses/isc.txt
new file mode 100644
index 0000000..788ed02
--- /dev/null
+++ b/resources/licenses/isc.txt
@@ -0,0 +1,15 @@
+ISC License
+
+Copyright (c) {{ year }} {{ organization }}{{ author }}
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
\ No newline at end of file
diff --git a/resources/licenses/lgpl-3.0-or-later.txt b/resources/licenses/lgpl-3.0-or-later.txt
new file mode 100644
index 0000000..9ca20a3
--- /dev/null
+++ b/resources/licenses/lgpl-3.0-or-later.txt
@@ -0,0 +1,17 @@
+GNU LESSER GENERAL PUBLIC LICENSE
+Version 3, 29 June 2007
+
+Copyright (C) {{ year }} {{ organization }}{{ author }}
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Lesser General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Lesser General Public License for more details.
+
+You should have received a copy of the GNU Lesser General Public License
+along with this program. If not, see .
\ No newline at end of file
diff --git a/resources/licenses/mit.txt b/resources/licenses/mit.txt
new file mode 100644
index 0000000..8403eef
--- /dev/null
+++ b/resources/licenses/mit.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) {{ year }} {{ organization }}{{ author }}
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
\ No newline at end of file
diff --git a/resources/licenses/mpl-2.0.txt b/resources/licenses/mpl-2.0.txt
new file mode 100644
index 0000000..7869ab3
--- /dev/null
+++ b/resources/licenses/mpl-2.0.txt
@@ -0,0 +1,185 @@
+Mozilla Public License Version 2.0
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+ means each individual or legal entity that creates, contributes to
+ the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+ means the combination of the Contributions of all Contributors
+ together with the information to which such Contributions relate and
+ which such Contributors would have been required to provide to the
+ Initial Developer or the Initial Developer's designated agent.
+
+1.3. "Covered Software"
+ means Source Code Form to which the Initial Developer has attached
+ the notice in Exhibit A, the Executable Form of such Source Code
+ Form, and Modifications of such Source Code Form, in each case
+ including portions thereof.
+
+1.4. "Executable"
+ means Covered Software in any form other than Source Code Form.
+
+1.5. "Initial Developer"
+ means the individual or entity that first makes Covered Software
+ available under this License.
+
+1.6. "Larger Work"
+ means a work which combines Covered Software or portions thereof
+ with code not governed by the terms of this License.
+
+1.7. "License"
+ means this document.
+
+1.8. "Licensable"
+ means having the right to grant, to the maximum extent possible,
+ whether at the time of the initial grant or subsequently acquired,
+ any rights expressed in this License.
+
+1.9. "Modifications"
+ means any of the following: (a) any file in Source Code Form that
+ results from an addition to, deletion from, or modification of the
+ contents of Covered Software; or (b) any new file in Source Code
+ Form that contains any Covered Software.
+
+1.10. "Patent Claims" of a Contributor
+ means any patent claim(s), including without limitation, method,
+ process, and apparatus claims, in any patent Licensable by such
+ Contributor that would be infringed, but for the grant of the
+ License, by making, using, or selling its Contributions, alone
+ or by combination with its Contributor Version (or portions of
+ such combination), to make, use, sell, offer for sale, have made,
+ import, or otherwise transfer either solely on its own or
+ together with its Contributor Version (or portions of such
+ combination), the Contributor Version where such Contributor
+ claims to be the source code of its Contributor Version, together
+ with the patent claims that are readable by the Contributor
+ Version that are necessarily infringed by its Contributor Version
+ alone (not together with any other Contributor Versions).
+
+1.11. "Source Code Form"
+ means the form of a work preferred for making modifications.
+
+1.12. "You" (or "Your")
+ means an individual or a legal entity exercising rights under this
+ License. For legal entities, "You" includes any entity that
+ controls, is controlled by, or is under common control with You.
+ For purposes of this definition, "control" means (a) the power,
+ direct or indirect, to cause the direction or management of such
+ entity, whether by contract or otherwise, or (b) ownership of
+ fifty percent (50%) or more of the outstanding shares or
+ beneficial ownership of such entity.
+
+2. License Grants and Conditions
+---------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants to You a perpetual, worldwide,
+non-exclusive, no-charge, royalty-free, irrevocable (except as stated
+in this section) patent license to make, use, sell, offer for sale,
+have made, import, and otherwise transfer its Contributions, alone
+or by combination with its Contributor Version (or portions of such
+combination), to make, use, sell, offer for sale, have made, import,
+or otherwise transfer the Contributor Version (or portions thereof).
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for the Contribution on the date the Contributor
+first places such Contribution in Source Code Form.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted
+under this License. No additional rights or licenses will be implied
+from the distribution or licensing of Covered Software under this
+License. Notwithstanding Section 2.1(b), nothing in this License is
+intended to affect any license to use code that is a part of the
+library definition (the "Library") while the Library is in both
+Source Code and Executable form, provided that such license extends
+only to the patent claims licensable by Contributors that are
+necessarily infringed by their Contributions alone or by combination
+of their Contributions with the Library.
+
+2.4. Third Party Patent Claims
+
+Should the Library be modified by a Contributor to cause the Library
+to fail to satisfy the conditions of this License, then You must
+terminate this License. In the event a Contributor causes the
+Library to fail to satisfy this License, then this License terminates
+immediately. Should a Contributor bring a patent claim against any
+Contributor alleging that the Library infringes such Contributor's
+patent claim, then the Contributor must terminate this License.
+
+2.5. Disclaimer of Warranty
+
+COVERED SOFTWARE IS PROVIDED UNDER THIS LICENSE ON AN "AS IS" BASIS,
+WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING,
+WITHOUT LIMITATION, WARRANTIES THAT THE COVERED SOFTWARE IS FREE OF
+DEFECTS, MERCHANTABLE, FIT FOR A PARTICULAR PURPOSE OR NON-INFRINGING.
+THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE COVERED
+SOFTWARE IS WITH YOU. SHOULD ANY COVERED SOFTWARE PROVE DEFECTIVE IN
+ANY RESPECT, YOU (NOT THE INITIAL DEVELOPER OR ANY OTHER CONTRIBUTOR)
+ASSUME THE COST OF ANY NECESSARY SERVICING, REPAIR OR CORRECTION. THIS
+DISCLAIMER OF WARRANTY CONSTITUTES AN ESSENTIAL PART OF THIS LICENSE.
+NO USE OF ANY COVERED SOFTWARE IS AUTHORIZED HEREUNDER EXCEPT UNDER
+THIS DISCLAIMER.
+
+2.6. Termination
+
+This License and the rights granted hereunder will terminate
+automatically if You fail to comply with any term(s) of this License
+and fail to cure such breach within 30 days of becoming aware of the
+breach. Provisions which, by their nature, must remain in effect
+beyond the termination of this License shall survive.
+
+2.7. Limitation of Liability
+
+UNDER NO CIRCUMSTANCES AND UNDER NO LEGAL THEORY, WHETHER TORT
+(INCLUDING NEGLIGENCE), CONTRACT, OR OTHERWISE, SHALL ANY INITIAL
+DEVELOPER, ANY OTHER CONTRIBUTOR, OR ANY DISTRIBUTOR OF COVERED
+SOFTWARE, OR ANY SUPPLIER OF ANY OF SUCH PARTIES, BE LIABLE TO ANY
+PERSON FOR ANY INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES OF ANY CHARACTER INCLUDING, WITHOUT LIMITATION, DAMAGES FOR
+LOSS OF GOODWILL, WORK STOPPAGE, COMPUTER FAILURE OR MALFUNCTION, OR
+ANY AND ALL OTHER COMMERCIAL DAMAGES OR LOSSES, EVEN IF SUCH PARTY
+SHALL HAVE BEEN INFORMED OF THE POSSIBILITY OF SUCH DAMAGES. THIS
+LIMITATION OF LIABILITY SHALL NOT APPLY TO LIABILITY FOR DEATH OR
+PERSONAL INJURY RESULTING FROM SUCH PARTY'S NEGLIGENCE TO THE EXTENT
+APPLICABLE LAW PROHIBITS SUCH LIMITATION. FURTHERMORE, NO CONTRIBUTOR
+SHALL BE LIABLE UNDER THIS LICENSE FOR ANY REVENUE LOSS, PROFIT LOSS
+OR DATA, OR FOR COSTS OF PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES,
+OR FOR ANY INDIRECT, CONSEQUENTIAL, PUNITIVE, OR SPECIAL DAMAGES
+WHATSOEVER ARISING OUT OF OR IN CONNECTION WITH THIS LICENSE, WHETHER
+SUCH PARTY IS ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+
+2.8. Acceptance
+
+This License is not intended to grant any rights to any person or
+entity other than the original licensees of the Covered Software,
+each of which has voluntarily agreed to the terms of this License.
+
+3. If You distribute or make the Covered Software available under
+this License in Source Code Form:
+
+ a) You must cause the Source Code Form to be prominently marked
+ with a simple text file (not a binary) containing the text:
+ "This file is part of the Covered Software. For copyright and
+ licensing information, see the file named COPYING in the main
+ directory of the Covered Software."
+
+ b) You must cause any files that contain modifications to the
+ Source Code Form to be clearly marked as such in a reasonable
+ manner (for example, by including them in a file named "NOTICE").
+
+ c) You must include in each file of Source Code Form at least the
+ following notice: "This file contains modifications made by You"
+ and a reference to this License.
+
+This License applies to all files in the Source Code Form that contain
+modifications made by You.
+
+Copyright {{ year }} {{ organization }}{{ author }}
\ No newline at end of file
diff --git a/resources/licenses/unlicense.txt b/resources/licenses/unlicense.txt
new file mode 100644
index 0000000..6bb8a29
--- /dev/null
+++ b/resources/licenses/unlicense.txt
@@ -0,0 +1,24 @@
+This is free and unencumbered software released into the public domain.
+
+Anyone is free to copy, modify, publish, use, compile, sell, or
+distribute this software, either in source code form or as a compiled
+binary, for any purpose, commercial or non-commercial, and by any
+means.
+
+In jurisdictions that recognize copyright laws, the author or authors
+of this software dedicate any and all copyright interest in the
+software to the public domain. We make this dedication for the benefit
+of the public at large and to the detriment of our heirs and
+successors. We intend this dedication to be an overt act of
+relinquishment in perpetuity of all present and future rights to this
+software under copyright law.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
+OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
+ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+OTHER DEALINGS IN THE SOFTWARE.
+
+For more information, please refer to
\ No newline at end of file
diff --git a/src/Command/CopyLicenseCommand.php b/src/Command/CopyLicenseCommand.php
new file mode 100644
index 0000000..b09f6e4
--- /dev/null
+++ b/src/Command/CopyLicenseCommand.php
@@ -0,0 +1,118 @@
+
+ * @license https://opensource.org/licenses/MIT MIT License
+ *
+ * @see https://github.com/php-fast-forward/dev-tools
+ * @see https://github.com/php-fast-forward
+ * @see https://datatracker.ietf.org/doc/html/rfc2119
+ */
+
+namespace FastForward\DevTools\Command;
+
+use Composer\Factory;
+use FastForward\DevTools\License\Generator;
+use FastForward\DevTools\License\GeneratorInterface;
+use FastForward\DevTools\License\PlaceholderResolver;
+use FastForward\DevTools\License\Reader;
+use FastForward\DevTools\License\Resolver;
+use FastForward\DevTools\License\TemplateLoader;
+use SplFileObject;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Filesystem\Filesystem;
+
+/**
+ * Generates and copies LICENSE files to projects.
+ *
+ * This command generates a LICENSE file if one does not exist and a supported
+ * license is declared in composer.json.
+ */
+final class CopyLicenseCommand extends AbstractCommand
+{
+ /**
+ * Creates a new CopyLicenseCommand instance.
+ *
+ * @param Filesystem|null $filesystem the filesystem component
+ * @param GeneratorInterface|null $generator the generator component
+ */
+ public function __construct(
+ ?Filesystem $filesystem = null,
+ private readonly ?GeneratorInterface $generator = null,
+ ) {
+ parent::__construct($filesystem);
+ }
+
+ /**
+ * @return GeneratorInterface
+ */
+ private function getGenerator(): GeneratorInterface
+ {
+ return $this->generator ?? new Generator(
+ new Reader(new SplFileObject(Factory::getComposerFile())),
+ new Resolver(),
+ new TemplateLoader(),
+ new PlaceholderResolver(),
+ $this->filesystem,
+ );
+ }
+
+ /**
+ * Configures the current command.
+ *
+ * This method MUST define the name, description, and help text for the command.
+ */
+ protected function configure(): void
+ {
+ $this
+ ->setName('license')
+ ->setDescription('Generates a LICENSE file from composer.json license information.')
+ ->setHelp(
+ 'This command generates a LICENSE file if one does not exist and a supported license is declared in composer.json.'
+ );
+ }
+
+ /**
+ * Executes the license generation process.
+ *
+ * Generates a LICENSE file if one does not exist and a supported license is declared in composer.json.
+ *
+ * @param InputInterface $input the input interface
+ * @param OutputInterface $output the output interface
+ *
+ * @return int the status code
+ */
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $targetPath = $this->getConfigFile('LICENSE', true);
+
+ if ($this->filesystem->exists($targetPath)) {
+ $output->writeln('LICENSE file already exists. Skipping generation.');
+
+ return self::SUCCESS;
+ }
+
+ $license = $this->getGenerator()
+ ->generate($targetPath);
+
+ if (null === $license) {
+ $output->writeln(
+ 'No supported license found in composer.json or license is unsupported. Skipping LICENSE generation.'
+ );
+
+ return self::SUCCESS;
+ }
+
+ $output->writeln('LICENSE file generated successfully.');
+
+ return self::SUCCESS;
+ }
+}
diff --git a/src/Command/SyncCommand.php b/src/Command/SyncCommand.php
index 5911015..2384a43 100644
--- a/src/Command/SyncCommand.php
+++ b/src/Command/SyncCommand.php
@@ -76,6 +76,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$this->addRepositoryWikiGitSubmodule();
$this->runCommand('gitignore', $output);
$this->runCommand('skills', $output);
+ $this->runCommand('license', $output);
return self::SUCCESS;
}
diff --git a/src/Composer/Capability/DevToolsCommandProvider.php b/src/Composer/Capability/DevToolsCommandProvider.php
index c031c1e..515f3e6 100644
--- a/src/Composer/Capability/DevToolsCommandProvider.php
+++ b/src/Composer/Capability/DevToolsCommandProvider.php
@@ -21,6 +21,7 @@
use FastForward\DevTools\Command\AbstractCommand;
use Composer\Plugin\Capability\CommandProvider as CommandProviderCapability;
use FastForward\DevTools\Command\CodeStyleCommand;
+use FastForward\DevTools\Command\CopyLicenseCommand;
use FastForward\DevTools\Command\DependenciesCommand;
use FastForward\DevTools\Command\DocsCommand;
use FastForward\DevTools\Command\GitIgnoreCommand;
@@ -62,6 +63,7 @@ public function getCommands()
new SyncCommand(),
new GitIgnoreCommand(),
new SkillsCommand(),
+ new CopyLicenseCommand(),
];
}
}
diff --git a/src/License/Generator.php b/src/License/Generator.php
new file mode 100644
index 0000000..45c99aa
--- /dev/null
+++ b/src/License/Generator.php
@@ -0,0 +1,99 @@
+
+ * @license https://opensource.org/licenses/MIT MIT License
+ *
+ * @see https://github.com/php-fast-forward/dev-tools
+ * @see https://github.com/php-fast-forward
+ * @see https://datatracker.ietf.org/doc/html/rfc2119
+ */
+
+namespace FastForward\DevTools\License;
+
+use Symfony\Component\Filesystem\Filesystem;
+
+final readonly class Generator implements GeneratorInterface
+{
+ /**
+ * @param Reader $reader
+ * @param Resolver $resolver
+ * @param TemplateLoader $templateLoader
+ * @param PlaceholderResolver $placeholderResolver
+ * @param Filesystem $filesystem
+ */
+ public function __construct(
+ private Reader $reader,
+ private Resolver $resolver,
+ private TemplateLoader $templateLoader,
+ private PlaceholderResolver $placeholderResolver,
+ private Filesystem $filesystem = new Filesystem()
+ ) {}
+
+ /**
+ * @param string $targetPath
+ *
+ * @return string|null
+ */
+ public function generate(string $targetPath): ?string
+ {
+ $license = $this->reader->getLicense();
+
+ if (null === $license) {
+ return null;
+ }
+
+ if (! $this->resolver->isSupported($license)) {
+ return null;
+ }
+
+ if ($this->filesystem->exists($targetPath)) {
+ return null;
+ }
+
+ $templateFilename = $this->resolver->resolve($license);
+
+ if (null === $templateFilename) {
+ return null;
+ }
+
+ $template = $this->templateLoader->load($templateFilename);
+
+ $authors = $this->reader->getAuthors();
+ $firstAuthor = $authors[0] ?? null;
+
+ $metadata = [
+ 'year' => $this->reader->getYear(),
+ 'organization' => $this->reader->getVendor(),
+ 'author' => null !== $firstAuthor ? ($firstAuthor['name'] ?: ($firstAuthor['email'] ?? '')) : '',
+ 'project' => $this->reader->getPackageName(),
+ ];
+
+ $content = $this->placeholderResolver->resolve($template, $metadata);
+
+ $this->filesystem->dumpFile($targetPath, $content);
+
+ return $content;
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasLicense(): bool
+ {
+ $license = $this->reader->getLicense();
+
+ if (null === $license) {
+ return false;
+ }
+
+ return $this->resolver->isSupported($license);
+ }
+}
diff --git a/src/License/GeneratorInterface.php b/src/License/GeneratorInterface.php
new file mode 100644
index 0000000..21425e1
--- /dev/null
+++ b/src/License/GeneratorInterface.php
@@ -0,0 +1,34 @@
+
+ * @license https://opensource.org/licenses/MIT MIT License
+ *
+ * @see https://github.com/php-fast-forward/dev-tools
+ * @see https://github.com/php-fast-forward
+ * @see https://datatracker.ietf.org/doc/html/rfc2119
+ */
+
+namespace FastForward\DevTools\License;
+
+interface GeneratorInterface
+{
+ /**
+ * @param string $targetPath
+ *
+ * @return string|null
+ */
+ public function generate(string $targetPath): ?string;
+
+ /**
+ * @return bool
+ */
+ public function hasLicense(): bool;
+}
diff --git a/src/License/PlaceholderResolver.php b/src/License/PlaceholderResolver.php
new file mode 100644
index 0000000..d28de3d
--- /dev/null
+++ b/src/License/PlaceholderResolver.php
@@ -0,0 +1,51 @@
+
+ * @license https://opensource.org/licenses/MIT MIT License
+ *
+ * @see https://github.com/php-fast-forward/dev-tools
+ * @see https://github.com/php-fast-forward
+ * @see https://datatracker.ietf.org/doc/html/rfc2119
+ */
+
+namespace FastForward\DevTools\License;
+
+use function Safe\preg_replace;
+
+final class PlaceholderResolver implements PlaceholderResolverInterface
+{
+ /**
+ * @param array{year?: int, organization?: string, author?: string, project?: string} $metadata
+ * @param string $template
+ */
+ public function resolve(string $template, array $metadata): string
+ {
+ $replacements = [
+ '{{ year }}' => (string) ($metadata['year'] ?? date('Y')),
+ '{{ organization }}' => $metadata['organization'] ?? '',
+ '{{ author }}' => $metadata['author'] ?? '',
+ '{{ project }}' => $metadata['project'] ?? '',
+ '{{ copyright_holder }}' => $metadata['organization'] ?? $metadata['author'] ?? '',
+ ];
+
+ $result = $template;
+
+ foreach ($replacements as $placeholder => $value) {
+ $result = str_replace($placeholder, $value, $result);
+ }
+
+ $result = preg_replace('/\{\{\s*\w+\s*\}\}/', '', $result);
+
+ $result = preg_replace('/\n{3,}/', "\n\n", $result);
+
+ return trim((string) $result);
+ }
+}
diff --git a/src/License/PlaceholderResolverInterface.php b/src/License/PlaceholderResolverInterface.php
new file mode 100644
index 0000000..564e90b
--- /dev/null
+++ b/src/License/PlaceholderResolverInterface.php
@@ -0,0 +1,28 @@
+
+ * @license https://opensource.org/licenses/MIT MIT License
+ *
+ * @see https://github.com/php-fast-forward/dev-tools
+ * @see https://github.com/php-fast-forward
+ * @see https://datatracker.ietf.org/doc/html/rfc2119
+ */
+
+namespace FastForward\DevTools\License;
+
+interface PlaceholderResolverInterface
+{
+ /**
+ * @param string $template
+ * @param array{year?: int, organization?: string, author?: string, project?: string} $metadata
+ */
+ public function resolve(string $template, array $metadata): string;
+}
diff --git a/src/License/Reader.php b/src/License/Reader.php
new file mode 100644
index 0000000..871d3b8
--- /dev/null
+++ b/src/License/Reader.php
@@ -0,0 +1,141 @@
+
+ * @license https://opensource.org/licenses/MIT MIT License
+ *
+ * @see https://github.com/php-fast-forward/dev-tools
+ * @see https://github.com/php-fast-forward
+ * @see https://datatracker.ietf.org/doc/html/rfc2119
+ */
+
+namespace FastForward\DevTools\License;
+
+use Safe\Exceptions\JsonException;
+use SplFileObject;
+
+use function Safe\json_decode;
+
+final readonly class Reader implements ReaderInterface
+{
+ private array $data;
+
+ /**
+ * @param SplFileObject $source The source file to read from, typically composer.json
+ */
+ public function __construct(SplFileObject $source)
+ {
+ $this->data = $this->readData($source);
+ }
+
+ /**
+ * @param SplFileObject $source The source file to read from, typically composer.json
+ *
+ * @return array
+ *
+ * @throws JsonException if the JSON is invalid
+ */
+ private function readData(SplFileObject $source): array
+ {
+ $content = $source->fread($source->getSize());
+
+ return json_decode($content, true);
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getLicense(): ?string
+ {
+ $license = $this->data['license'] ?? [];
+
+ if (\is_string($license)) {
+ return $license;
+ }
+
+ return $this->extractLicense($license);
+ }
+
+ /**
+ * @return string
+ */
+ public function getPackageName(): string
+ {
+ return $this->data['name'] ?? '';
+ }
+
+ /**
+ * @return array
+ */
+ public function getAuthors(): array
+ {
+ $authors = $this->data['authors'] ?? [];
+
+ if ([] === $authors) {
+ return [];
+ }
+
+ return array_map(
+ static fn(array $author): array => [
+ 'name' => $author['name'] ?? '',
+ 'email' => $author['email'] ?? '',
+ 'homepage' => $author['homepage'] ?? '',
+ 'role' => $author['role'] ?? '',
+ ],
+ $authors
+ );
+ }
+
+ /**
+ * @return string|null
+ */
+ public function getVendor(): ?string
+ {
+ $packageName = $this->getPackageName();
+
+ if ('' === $packageName) {
+ return null;
+ }
+
+ $parts = explode('/', $packageName, 2);
+
+ if (! isset($parts[1])) {
+ return null;
+ }
+
+ return $parts[0];
+ }
+
+ /**
+ * @return int
+ */
+ public function getYear(): int
+ {
+ return (int) date('Y');
+ }
+
+ /**
+ * @param array $license
+ *
+ * @return string|null
+ */
+ private function extractLicense(array $license): ?string
+ {
+ if ([] === $license) {
+ return null;
+ }
+
+ if (1 === \count($license)) {
+ return $license[0];
+ }
+
+ return null;
+ }
+}
diff --git a/src/License/ReaderInterface.php b/src/License/ReaderInterface.php
new file mode 100644
index 0000000..06be9f1
--- /dev/null
+++ b/src/License/ReaderInterface.php
@@ -0,0 +1,47 @@
+
+ * @license https://opensource.org/licenses/MIT MIT License
+ *
+ * @see https://github.com/php-fast-forward/dev-tools
+ * @see https://github.com/php-fast-forward
+ * @see https://datatracker.ietf.org/doc/html/rfc2119
+ */
+
+namespace FastForward\DevTools\License;
+
+interface ReaderInterface
+{
+ /**
+ * @return string|null
+ */
+ public function getLicense(): ?string;
+
+ /**
+ * @return string
+ */
+ public function getPackageName(): string;
+
+ /**
+ * @return array
+ */
+ public function getAuthors(): array;
+
+ /**
+ * @return string|null
+ */
+ public function getVendor(): ?string;
+
+ /**
+ * @return int
+ */
+ public function getYear(): int;
+}
diff --git a/src/License/Resolver.php b/src/License/Resolver.php
new file mode 100644
index 0000000..3de3ab1
--- /dev/null
+++ b/src/License/Resolver.php
@@ -0,0 +1,75 @@
+
+ * @license https://opensource.org/licenses/MIT MIT License
+ *
+ * @see https://github.com/php-fast-forward/dev-tools
+ * @see https://github.com/php-fast-forward
+ * @see https://datatracker.ietf.org/doc/html/rfc2119
+ */
+
+namespace FastForward\DevTools\License;
+
+final class Resolver implements ResolverInterface
+{
+ private const array SUPPORTED_LICENSES = [
+ 'MIT' => 'mit.txt',
+ 'BSD-2-Clause' => 'bsd-2-clause.txt',
+ 'BSD-3-Clause' => 'bsd-3-clause.txt',
+ 'Apache-2.0' => 'apache-2.0.txt',
+ 'Apache-2' => 'apache-2.0.txt',
+ 'GPL-3.0-or-later' => 'gpl-3.0-or-later.txt',
+ 'GPL-3.0' => 'gpl-3.0-or-later.txt',
+ 'GPL-3+' => 'gpl-3.0-or-later.txt',
+ 'LGPL-3.0-or-later' => 'lgpl-3.0-or-later.txt',
+ 'LGPL-3.0' => 'lgpl-3.0-or-later.txt',
+ 'LGPL-3+' => 'lgpl-3.0-or-later.txt',
+ 'MPL-2.0' => 'mpl-2.0.txt',
+ 'ISC' => 'isc.txt',
+ 'Unlicense' => 'unlicense.txt',
+ ];
+
+ /**
+ * @param string $license
+ *
+ * @return bool
+ */
+ public function isSupported(string $license): bool
+ {
+ return isset(self::SUPPORTED_LICENSES[$this->normalize($license)]);
+ }
+
+ /**
+ * @param string $license
+ *
+ * @return string|null
+ */
+ public function resolve(string $license): ?string
+ {
+ $normalized = $this->normalize($license);
+
+ if (! isset(self::SUPPORTED_LICENSES[$normalized])) {
+ return null;
+ }
+
+ return self::SUPPORTED_LICENSES[$normalized];
+ }
+
+ /**
+ * @param string $license
+ *
+ * @return string
+ */
+ private function normalize(string $license): string
+ {
+ return trim($license);
+ }
+}
diff --git a/src/License/ResolverInterface.php b/src/License/ResolverInterface.php
new file mode 100644
index 0000000..3e018a1
--- /dev/null
+++ b/src/License/ResolverInterface.php
@@ -0,0 +1,36 @@
+
+ * @license https://opensource.org/licenses/MIT MIT License
+ *
+ * @see https://github.com/php-fast-forward/dev-tools
+ * @see https://github.com/php-fast-forward
+ * @see https://datatracker.ietf.org/doc/html/rfc2119
+ */
+
+namespace FastForward\DevTools\License;
+
+interface ResolverInterface
+{
+ /**
+ * @param string $license
+ *
+ * @return bool
+ */
+ public function isSupported(string $license): bool;
+
+ /**
+ * @param string $license
+ *
+ * @return string|null
+ */
+ public function resolve(string $license): ?string;
+}
diff --git a/src/License/TemplateLoader.php b/src/License/TemplateLoader.php
new file mode 100644
index 0000000..6c72881
--- /dev/null
+++ b/src/License/TemplateLoader.php
@@ -0,0 +1,58 @@
+
+ * @license https://opensource.org/licenses/MIT MIT License
+ *
+ * @see https://github.com/php-fast-forward/dev-tools
+ * @see https://github.com/php-fast-forward
+ * @see https://datatracker.ietf.org/doc/html/rfc2119
+ */
+
+namespace FastForward\DevTools\License;
+
+use RuntimeException;
+
+use function Safe\file_get_contents;
+
+final readonly class TemplateLoader implements TemplateLoaderInterface
+{
+ private string $templatesPath;
+
+ /**
+ * @param string|null $templatesPath
+ */
+ public function __construct(?string $templatesPath = null)
+ {
+ $this->templatesPath = $templatesPath ?? \dirname(__DIR__, 2) . '/resources/licenses';
+ }
+
+ /**
+ * @param string $templateFilename
+ *
+ * @return string
+ *
+ * @throws RuntimeException
+ */
+ public function load(string $templateFilename): string
+ {
+ $templatePath = $this->templatesPath . '/' . $templateFilename;
+
+ if (! file_exists($templatePath)) {
+ throw new RuntimeException(\sprintf(
+ 'License template "%s" not found in "%s"',
+ $templateFilename,
+ $this->templatesPath
+ ));
+ }
+
+ return file_get_contents($templatePath);
+ }
+}
diff --git a/src/License/TemplateLoaderInterface.php b/src/License/TemplateLoaderInterface.php
new file mode 100644
index 0000000..4b8f797
--- /dev/null
+++ b/src/License/TemplateLoaderInterface.php
@@ -0,0 +1,29 @@
+
+ * @license https://opensource.org/licenses/MIT MIT License
+ *
+ * @see https://github.com/php-fast-forward/dev-tools
+ * @see https://github.com/php-fast-forward
+ * @see https://datatracker.ietf.org/doc/html/rfc2119
+ */
+
+namespace FastForward\DevTools\License;
+
+interface TemplateLoaderInterface
+{
+ /**
+ * @param string $templateFilename
+ *
+ * @return string
+ */
+ public function load(string $templateFilename): string;
+}
diff --git a/tests/Command/CopyLicenseCommandTest.php b/tests/Command/CopyLicenseCommandTest.php
new file mode 100644
index 0000000..1cf0a41
--- /dev/null
+++ b/tests/Command/CopyLicenseCommandTest.php
@@ -0,0 +1,103 @@
+
+ * @license https://opensource.org/licenses/MIT MIT License
+ *
+ * @see https://github.com/php-fast-forward/dev-tools
+ * @see https://github.com/php-fast-forward
+ * @see https://datatracker.ietf.org/doc/html/rfc2119
+ */
+
+namespace FastForward\DevTools\Tests\Command;
+
+use FastForward\DevTools\Command\CopyLicenseCommand;
+use FastForward\DevTools\License\Generator;
+use FastForward\DevTools\License\PlaceholderResolver;
+use FastForward\DevTools\License\Reader;
+use FastForward\DevTools\License\Resolver;
+use FastForward\DevTools\License\TemplateLoader;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\Attributes\UsesClass;
+use Prophecy\Argument;
+use Prophecy\PhpUnit\ProphecyTrait;
+
+#[CoversClass(CopyLicenseCommand::class)]
+#[UsesClass(Reader::class)]
+#[UsesClass(Resolver::class)]
+#[UsesClass(TemplateLoader::class)]
+#[UsesClass(PlaceholderResolver::class)]
+#[UsesClass(Generator::class)]
+final class CopyLicenseCommandTest extends AbstractCommandTestCase
+{
+ use ProphecyTrait;
+
+ /**
+ * @return string
+ */
+ protected function getCommandClass(): string
+ {
+ return CopyLicenseCommand::class;
+ }
+
+ /**
+ * @return string
+ */
+ protected function getCommandName(): string
+ {
+ return 'license';
+ }
+
+ /**
+ * @return string
+ */
+ protected function getCommandDescription(): string
+ {
+ return 'Generates a LICENSE file from composer.json license information.';
+ }
+
+ /**
+ * @return string
+ */
+ protected function getCommandHelp(): string
+ {
+ return 'This command generates a LICENSE file if one does not exist and a supported license is declared in composer.json.';
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function executeWillReturnSuccessAndWriteInfo(): void
+ {
+ $this->filesystem->exists(Argument::type('string'))->willReturn(false);
+ $this->filesystem->dumpFile(Argument::cetera())->shouldBeCalled();
+
+ $this->output->writeln(Argument::type('string'))
+ ->shouldBeCalled();
+
+ self::assertSame(CopyLicenseCommand::SUCCESS, $this->invokeExecute());
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function executeWillSkipWhenLicenseFileExists(): void
+ {
+ $this->filesystem->exists(Argument::type('string'))->willReturn(true);
+
+ $this->output->writeln(Argument::type('string'))
+ ->shouldBeCalled();
+
+ self::assertSame(CopyLicenseCommand::SUCCESS, $this->invokeExecute());
+ }
+}
diff --git a/tests/Command/DependenciesCommandTest.php b/tests/Command/DependenciesCommandTest.php
index 57976bd..6a8ba4b 100644
--- a/tests/Command/DependenciesCommandTest.php
+++ b/tests/Command/DependenciesCommandTest.php
@@ -70,6 +70,8 @@ protected function setUp(): void
{
parent::setUp();
+ $this->output->writeln(Argument::type('string'));
+
$cwd = getcwd();
$this->filesystem->exists($cwd . '/composer.json')->willReturn(true);
}
@@ -104,10 +106,6 @@ public function executeWillReturnSuccessWhenBothToolsSucceed(): void
$this->output->writeln('Running dependency analysis...')
->shouldBeCalled();
- $this->output->writeln(
- 'Warning: TTY is not supported. The command may not display output as expected.'
- )
- ->shouldBeCalled();
self::assertSame(DependenciesCommand::SUCCESS, $this->invokeExecute());
}
@@ -142,10 +140,6 @@ public function executeWillReturnFailureWhenFirstToolFails(): void
$this->output->writeln('Running dependency analysis...')
->shouldBeCalled();
- $this->output->writeln(
- 'Warning: TTY is not supported. The command may not display output as expected.'
- )
- ->shouldBeCalled();
self::assertSame(DependenciesCommand::FAILURE, $this->invokeExecute());
}
@@ -180,10 +174,6 @@ public function executeWillReturnFailureWhenSecondToolFails(): void
$this->output->writeln('Running dependency analysis...')
->shouldBeCalled();
- $this->output->writeln(
- 'Warning: TTY is not supported. The command may not display output as expected.'
- )
- ->shouldBeCalled();
self::assertSame(DependenciesCommand::FAILURE, $this->invokeExecute());
}
@@ -230,10 +220,6 @@ public function executeWillCallBothDependencyToolsWithComposerJson(): void
$this->output->writeln('Running dependency analysis...')
->shouldBeCalled();
- $this->output->writeln(
- 'Warning: TTY is not supported. The command may not display output as expected.'
- )
- ->shouldBeCalled();
self::assertSame(DependenciesCommand::SUCCESS, $this->invokeExecute());
}
diff --git a/tests/Composer/Capability/DevToolsCommandProviderTest.php b/tests/Composer/Capability/DevToolsCommandProviderTest.php
index b867d01..9067fdd 100644
--- a/tests/Composer/Capability/DevToolsCommandProviderTest.php
+++ b/tests/Composer/Capability/DevToolsCommandProviderTest.php
@@ -20,6 +20,7 @@
use FastForward\DevTools\Command\AbstractCommand;
use FastForward\DevTools\Command\CodeStyleCommand;
+use FastForward\DevTools\Command\CopyLicenseCommand;
use FastForward\DevTools\Command\DependenciesCommand;
use FastForward\DevTools\Command\DocsCommand;
use FastForward\DevTools\Command\GitIgnoreCommand;
@@ -53,6 +54,7 @@
#[UsesClass(SyncCommand::class)]
#[UsesClass(GitIgnoreCommand::class)]
#[UsesClass(SkillsCommand::class)]
+#[UsesClass(CopyLicenseCommand::class)]
#[UsesClass(SkillsSynchronizer::class)]
#[UsesClass(Merger::class)]
#[UsesClass(Writer::class)]
@@ -88,6 +90,7 @@ public function getCommandsWillReturnAllSupportedCommandsInExpectedOrder(): void
new SyncCommand(),
new GitIgnoreCommand(),
new SkillsCommand(),
+ new CopyLicenseCommand(),
],
$this->commandProvider->getCommands(),
);
diff --git a/tests/License/PlaceholderResolverTest.php b/tests/License/PlaceholderResolverTest.php
new file mode 100644
index 0000000..6c5b726
--- /dev/null
+++ b/tests/License/PlaceholderResolverTest.php
@@ -0,0 +1,119 @@
+
+ * @license https://opensource.org/licenses/MIT MIT License
+ *
+ * @see https://github.com/php-fast-forward/dev-tools
+ * @see https://github.com/php-fast-forward
+ * @see https://datatracker.ietf.org/doc/html/rfc2119
+ */
+
+namespace FastForward\DevTools\Tests\License;
+
+use FastForward\DevTools\License\PlaceholderResolver;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\TestCase;
+
+#[CoversClass(PlaceholderResolver::class)]
+final class PlaceholderResolverTest extends TestCase
+{
+ private PlaceholderResolver $resolver;
+
+ /**
+ * @return void
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->resolver = new PlaceholderResolver();
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function resolveWithAllPlaceholdersWillReplaceAll(): void
+ {
+ $template = 'Copyright {{ year }} {{ organization }} {{ author }} {{ project }}';
+ $metadata = [
+ 'year' => 2026,
+ 'organization' => 'FastForward',
+ 'author' => 'Felipe',
+ 'project' => 'dev-tools',
+ ];
+
+ $result = $this->resolver->resolve($template, $metadata);
+
+ self::assertSame('Copyright 2026 FastForward Felipe dev-tools', $result);
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function resolveWithPartialMetadataWillReplaceAvailableAndRemoveOthers(): void
+ {
+ $template = 'Copyright {{ year }} {{ organization }}{{ author }}';
+ $metadata = [
+ 'year' => 2026,
+ ];
+
+ $result = $this->resolver->resolve($template, $metadata);
+
+ self::assertSame('Copyright 2026', $result);
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function resolveWithNoMetadataWillRemovePlaceholders(): void
+ {
+ $template = 'Copyright {{ year }} {{ organization }}{{ author }} {{ project }}';
+ $metadata = [];
+
+ $result = $this->resolver->resolve($template, $metadata);
+
+ self::assertStringNotContainsString('{{', $result);
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function resolveWillCollapseMultipleBlankLines(): void
+ {
+ $template = "Line 1\n\n\n\nLine 2";
+ $metadata = [];
+
+ $result = $this->resolver->resolve($template, $metadata);
+
+ self::assertSame("Line 1\n\nLine 2", $result);
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function resolveWillTrimResult(): void
+ {
+ $template = ' {{ year }} ';
+ $metadata = [
+ 'year' => 2026,
+ ];
+
+ $result = $this->resolver->resolve($template, $metadata);
+
+ self::assertSame('2026', $result);
+ }
+}
diff --git a/tests/License/ReaderTest.php b/tests/License/ReaderTest.php
new file mode 100644
index 0000000..a4907ab
--- /dev/null
+++ b/tests/License/ReaderTest.php
@@ -0,0 +1,197 @@
+
+ * @license https://opensource.org/licenses/MIT MIT License
+ *
+ * @see https://github.com/php-fast-forward/dev-tools
+ * @see https://github.com/php-fast-forward
+ * @see https://datatracker.ietf.org/doc/html/rfc2119
+ */
+
+namespace FastForward\DevTools\Tests\License;
+
+use FastForward\DevTools\License\Reader;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\TestCase;
+use Prophecy\PhpUnit\ProphecyTrait;
+use Prophecy\Prophecy\ObjectProphecy;
+use SplFileObject;
+
+use function Safe\json_encode;
+
+#[CoversClass(Reader::class)]
+final class ReaderTest extends TestCase
+{
+ use ProphecyTrait;
+
+ /**
+ * @param array $data
+ *
+ * @return void
+ */
+ private function createReader(array $data): Reader
+ {
+ $json = json_encode($data, \JSON_PRETTY_PRINT);
+
+ /** @var ObjectProphecy $file */
+ $file = $this->prophesize(SplFileObject::class);
+ $file->getSize()
+ ->willReturn(\strlen($json));
+ $file->fread(\strlen($json))
+ ->willReturn($json);
+
+ return new Reader($file->reveal());
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function getLicenseWithSingleLicenseWillReturnLicenseString(): void
+ {
+ $reader = $this->createReader([
+ 'name' => 'fast-forward/dev-tools',
+ 'license' => ['MIT'],
+ ]);
+
+ self::assertSame('MIT', $reader->getLicense());
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function getLicenseWithStringLicenseWillReturnLicenseString(): void
+ {
+ $reader = $this->createReader([
+ 'name' => 'fast-forward/dev-tools',
+ 'license' => 'MIT',
+ ]);
+
+ self::assertSame('MIT', $reader->getLicense());
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function getLicenseWithNoLicenseWillReturnNull(): void
+ {
+ $reader = $this->createReader([
+ 'name' => 'fast-forward/dev-tools',
+ ]);
+
+ self::assertNull($reader->getLicense());
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function getLicenseWithMultipleLicensesWillReturnNull(): void
+ {
+ $reader = $this->createReader([
+ 'name' => 'fast-forward/dev-tools',
+ 'license' => ['MIT', 'Apache-2.0'],
+ ]);
+
+ self::assertNull($reader->getLicense());
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function getPackageNameWillReturnPackageName(): void
+ {
+ $reader = $this->createReader([
+ 'name' => 'fast-forward/dev-tools',
+ ]);
+
+ self::assertSame('fast-forward/dev-tools', $reader->getPackageName());
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function getVendorWillExtractVendorFromPackageName(): void
+ {
+ $reader = $this->createReader([
+ 'name' => 'fast-forward/dev-tools',
+ ]);
+
+ self::assertSame('fast-forward', $reader->getVendor());
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function getVendorWithSingleNamePackageWillReturnNull(): void
+ {
+ $reader = $this->createReader([
+ 'name' => 'dev-tools',
+ ]);
+
+ self::assertNull($reader->getVendor());
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function getAuthorsWillReturnAuthorsArray(): void
+ {
+ $authors = [
+ [
+ 'name' => 'Felipe Abreu',
+ 'email' => 'test@example.com',
+ 'homepage' => 'https://example.com',
+ 'role' => 'Developer',
+ ],
+ ];
+
+ $reader = $this->createReader([
+ 'name' => 'fast-forward/dev-tools',
+ 'authors' => $authors,
+ ]);
+
+ self::assertSame($authors, $reader->getAuthors());
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function getAuthorsWithNoAuthorsWillReturnEmptyArray(): void
+ {
+ $reader = $this->createReader([
+ 'name' => 'fast-forward/dev-tools',
+ ]);
+
+ self::assertSame([], $reader->getAuthors());
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function getYearWillReturnCurrentYear(): void
+ {
+ $reader = $this->createReader([
+ 'name' => 'fast-forward/dev-tools',
+ ]);
+
+ self::assertSame((int) date('Y'), $reader->getYear());
+ }
+}
diff --git a/tests/License/ResolverTest.php b/tests/License/ResolverTest.php
new file mode 100644
index 0000000..a1f5e9a
--- /dev/null
+++ b/tests/License/ResolverTest.php
@@ -0,0 +1,130 @@
+
+ * @license https://opensource.org/licenses/MIT MIT License
+ *
+ * @see https://github.com/php-fast-forward/dev-tools
+ * @see https://github.com/php-fast-forward
+ * @see https://datatracker.ietf.org/doc/html/rfc2119
+ */
+
+namespace FastForward\DevTools\Tests\License;
+
+use FastForward\DevTools\License\Resolver;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\TestCase;
+
+#[CoversClass(Resolver::class)]
+final class ResolverTest extends TestCase
+{
+ private Resolver $resolver;
+
+ /**
+ * @return void
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->resolver = new Resolver();
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function isSupportedWithMITWillReturnTrue(): void
+ {
+ self::assertTrue($this->resolver->isSupported('MIT'));
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function isSupportedWithBSD3ClauseWillReturnTrue(): void
+ {
+ self::assertTrue($this->resolver->isSupported('BSD-3-Clause'));
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function isSupportedWithApache20WillReturnTrue(): void
+ {
+ self::assertTrue($this->resolver->isSupported('Apache-2.0'));
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function isSupportedWithApache2WillReturnTrue(): void
+ {
+ self::assertTrue($this->resolver->isSupported('Apache-2'));
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function isSupportedWithGPL3WillReturnTrue(): void
+ {
+ self::assertTrue($this->resolver->isSupported('GPL-3.0'));
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function isSupportedWithGPL3PlusWillReturnTrue(): void
+ {
+ self::assertTrue($this->resolver->isSupported('GPL-3+'));
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function isSupportedWithUnknownLicenseWillReturnFalse(): void
+ {
+ self::assertFalse($this->resolver->isSupported('Unknown-License'));
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function resolveWithMITWillReturnTemplateFilename(): void
+ {
+ self::assertSame('mit.txt', $this->resolver->resolve('MIT'));
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function resolveWithApache2WillReturnApache20Template(): void
+ {
+ self::assertSame('apache-2.0.txt', $this->resolver->resolve('Apache-2'));
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function resolveWithUnknownLicenseWillReturnNull(): void
+ {
+ self::assertNull($this->resolver->resolve('Unknown-License'));
+ }
+}
diff --git a/tests/License/TemplateLoaderTest.php b/tests/License/TemplateLoaderTest.php
new file mode 100644
index 0000000..c47f1d6
--- /dev/null
+++ b/tests/License/TemplateLoaderTest.php
@@ -0,0 +1,64 @@
+
+ * @license https://opensource.org/licenses/MIT MIT License
+ *
+ * @see https://github.com/php-fast-forward/dev-tools
+ * @see https://github.com/php-fast-forward
+ * @see https://datatracker.ietf.org/doc/html/rfc2119
+ */
+
+namespace FastForward\DevTools\Tests\License;
+
+use FastForward\DevTools\License\TemplateLoader;
+use PHPUnit\Framework\Attributes\CoversClass;
+use PHPUnit\Framework\Attributes\Test;
+use PHPUnit\Framework\TestCase;
+use RuntimeException;
+
+#[CoversClass(TemplateLoader::class)]
+final class TemplateLoaderTest extends TestCase
+{
+ private TemplateLoader $loader;
+
+ /**
+ * @return void
+ */
+ protected function setUp(): void
+ {
+ parent::setUp();
+
+ $this->loader = new TemplateLoader();
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function loadWithExistingTemplateWillReturnContent(): void
+ {
+ $content = $this->loader->load('mit.txt');
+
+ self::assertStringContainsString('MIT', $content);
+ }
+
+ /**
+ * @return void
+ */
+ #[Test]
+ public function loadWithNonExistentTemplateWillThrow(): void
+ {
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('not found');
+
+ $this->loader->load('nonexistent.txt');
+ }
+}