diff --git a/doc/03-cli.md b/doc/03-cli.md index f7df60b92..29eeb398c 100644 --- a/doc/03-cli.md +++ b/doc/03-cli.md @@ -420,6 +420,11 @@ This implies `--by-package --by-suggestion`, showing both lists. * **--by-suggestion:** Groups output by suggested package. * **--no-dev:** Excludes suggestions from `require-dev` packages. +## fund + +Discover how to help fund the maintenance of your dependencies. This lists +all funding links from the installed dependencies. + ## depends (why) The `depends` command tells you which other packages depend on a certain diff --git a/doc/04-schema.md b/doc/04-schema.md index 6e273c1a0..ecff3d996 100644 --- a/doc/04-schema.md +++ b/doc/04-schema.md @@ -258,6 +258,39 @@ An example: Optional. +### funding + +A list of URLs to provide funding to the package authors for maintenance and +development of new functionality. + +Each entry consists of the following + +* **type:** The type of funding or the platform through which funding can be provided, e.g. patreon, opencollective, tidelift or github. +* **url:** URL to a website with details and a way to fund the package. + +An example: + +```json +{ + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/phpdoctrine" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/subscription/pkg/packagist-doctrine_doctrine-bundle" + }, + { + "type": "other", + "url": "https://www.doctrine-project.org/sponsorship.html" + } + ] +} +``` + +Optional. + ### Package links All of the following take an object which maps package names to diff --git a/res/composer-schema.json b/res/composer-schema.json index c83109151..a74819baa 100644 --- a/res/composer-schema.json +++ b/res/composer-schema.json @@ -522,6 +522,24 @@ } } }, + "funding": { + "type": "array", + "description": "A list of options to fund the development and maintenance of the package.", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Type of funding or platform through which funding is possible." + }, + "url": { + "type": "string", + "description": "URL to a website with details on funding and a way to fund the package.", + "format": "uri" + } + } + } + }, "non-feature-branches": { "type": ["array"], "description": "A set of string or regex patterns for non-numeric branch names that will not be handled as feature branches.", diff --git a/src/Composer/Command/FundCommand.php b/src/Composer/Command/FundCommand.php new file mode 100644 index 000000000..3c80abc23 --- /dev/null +++ b/src/Composer/Command/FundCommand.php @@ -0,0 +1,89 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Command; + +use Composer\Package\CompletePackageInterface; +use Composer\Package\AliasPackage; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +/** + * @author Nicolas Grekas + * @author Jordi Boggiano + */ +class FundCommand extends BaseCommand +{ + protected function configure() + { + $this->setName('fund') + ->setDescription('Discover how to help fund the maintenance of your dependencies.') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $composer = $this->getComposer(); + + $repo = $composer->getRepositoryManager()->getLocalRepository(); + $fundings = array(); + foreach ($repo->getPackages() as $package) { + if ($package instanceof AliasPackage) { + continue; + } + if ($package instanceof CompletePackageInterface && $funding = $package->getFunding()) { + foreach ($funding as $fundingOption) { + list($vendor, $packageName) = explode('/', $package->getPrettyName()); + $url = $fundingOption['url']; + if (!empty($fundingOption['type']) && $fundingOption['type'] === 'github' && preg_match('{^https://github.com/([^/]+)$}', $url, $match)) { + $url = 'https://github.com/sponsors/'.$match[1]; + } + $fundings[$vendor][$url][] = $packageName; + } + } + } + + ksort($fundings); + + $io = $this->getIO(); + + if ($fundings) { + $prev = null; + + $io->write('The following packages were found in your dependencies which publish funding information:'); + + foreach ($fundings as $vendor => $links) { + $io->write(''); + $io->write(sprintf("%s", $vendor)); + foreach ($links as $url => $packages) { + $line = sprintf(' %s', implode(', ', $packages)); + + if ($prev !== $line) { + $io->write($line); + $prev = $line; + } + + $io->write(sprintf(' %s', $url)); + } + } + + $io->write(""); + $io->write("Please consider following these links and sponsoring the work of package authors!"); + $io->write("Thank you!"); + } else { + $io->write("No funding links were found in your package dependencies. This doesn't mean they don't need your support!"); + } + + return 0; + } +} diff --git a/src/Composer/Console/Application.php b/src/Composer/Console/Application.php index b884de15e..74e270a96 100644 --- a/src/Composer/Console/Application.php +++ b/src/Composer/Console/Application.php @@ -436,6 +436,7 @@ class Application extends BaseApplication new Command\ExecCommand(), new Command\OutdatedCommand(), new Command\CheckPlatformReqsCommand(), + new Command\FundCommand(), )); if ('phar:' === substr(__FILE__, 0, 5)) { diff --git a/src/Composer/Installer.php b/src/Composer/Installer.php index a789b4c88..ff40b618f 100644 --- a/src/Composer/Installer.php +++ b/src/Composer/Installer.php @@ -35,6 +35,7 @@ use Composer\IO\IOInterface; use Composer\Package\AliasPackage; use Composer\Package\BasePackage; use Composer\Package\CompletePackage; +use Composer\Package\CompletePackageInterface; use Composer\Package\Link; use Composer\Package\Loader\ArrayLoader; use Composer\Package\Dumper\ArrayDumper; @@ -313,6 +314,24 @@ class Installer } } + $fundingCount = 0; + foreach ($localRepo->getPackages() as $package) { + if ($package instanceof CompletePackageInterface && !$package instanceof AliasPackage && $package->getFunding()) { + $fundingCount++; + } + } + if ($fundingCount) { + $this->io->writeError(array( + sprintf( + "%d package%s you are using %s looking for funding.", + $fundingCount, + 1 === $fundingCount ? '' : 's', + 1 === $fundingCount ? 'is' : 'are' + ), + 'Use the composer fund command to find out more!', + )); + } + if ($this->runScripts) { // dispatch post event $eventName = $this->update ? ScriptEvents::POST_UPDATE_CMD : ScriptEvents::POST_INSTALL_CMD; diff --git a/src/Composer/Package/AliasPackage.php b/src/Composer/Package/AliasPackage.php index 89f197856..ee93ec497 100644 --- a/src/Composer/Package/AliasPackage.php +++ b/src/Composer/Package/AliasPackage.php @@ -377,6 +377,11 @@ class AliasPackage extends BasePackage implements CompletePackageInterface return $this->aliasOf->getSupport(); } + public function getFunding() + { + return $this->aliasOf->getFunding(); + } + public function getNotificationUrl() { return $this->aliasOf->getNotificationUrl(); diff --git a/src/Composer/Package/CompletePackage.php b/src/Composer/Package/CompletePackage.php index 5dbdb82e3..785d5817c 100644 --- a/src/Composer/Package/CompletePackage.php +++ b/src/Composer/Package/CompletePackage.php @@ -27,6 +27,7 @@ class CompletePackage extends Package implements CompletePackageInterface protected $homepage; protected $scripts = array(); protected $support = array(); + protected $funding = array(); protected $abandoned = false; /** @@ -171,6 +172,24 @@ class CompletePackage extends Package implements CompletePackageInterface return $this->support; } + /** + * Set the funding + * + * @param array $funding + */ + public function setFunding(array $funding) + { + $this->funding = $funding; + } + + /** + * {@inheritDoc} + */ + public function getFunding() + { + return $this->funding; + } + /** * @return bool */ diff --git a/src/Composer/Package/CompletePackageInterface.php b/src/Composer/Package/CompletePackageInterface.php index 4036b3cec..7782886d3 100644 --- a/src/Composer/Package/CompletePackageInterface.php +++ b/src/Composer/Package/CompletePackageInterface.php @@ -79,6 +79,15 @@ interface CompletePackageInterface extends PackageInterface */ public function getSupport(); + /** + * Returns an array of funding options for the package + * + * Each item will contain type and url keys + * + * @return array + */ + public function getFunding(); + /** * Returns if the package is abandoned or not * diff --git a/src/Composer/Package/Dumper/ArrayDumper.php b/src/Composer/Package/Dumper/ArrayDumper.php index b1e20dbf5..dece598f1 100644 --- a/src/Composer/Package/Dumper/ArrayDumper.php +++ b/src/Composer/Package/Dumper/ArrayDumper.php @@ -104,6 +104,7 @@ class ArrayDumper 'keywords', 'repositories', 'support', + 'funding', ); $data = $this->dumpValues($package, $keys, $data); diff --git a/src/Composer/Package/Loader/ArrayLoader.php b/src/Composer/Package/Loader/ArrayLoader.php index c269afa22..228632b42 100644 --- a/src/Composer/Package/Loader/ArrayLoader.php +++ b/src/Composer/Package/Loader/ArrayLoader.php @@ -198,6 +198,10 @@ class ArrayLoader implements LoaderInterface $package->setSupport($config['support']); } + if (!empty($config['funding']) && is_array($config['funding'])) { + $package->setFunding($config['funding']); + } + if (isset($config['abandoned'])) { $package->setAbandoned($config['abandoned']); } diff --git a/src/Composer/Package/Loader/ValidatingArrayLoader.php b/src/Composer/Package/Loader/ValidatingArrayLoader.php index 43f23236b..f02f6b165 100644 --- a/src/Composer/Package/Loader/ValidatingArrayLoader.php +++ b/src/Composer/Package/Loader/ValidatingArrayLoader.php @@ -193,6 +193,32 @@ class ValidatingArrayLoader implements LoaderInterface } } + if ($this->validateArray('funding') && !empty($this->config['funding'])) { + foreach ($this->config['funding'] as $key => $fundingOption) { + if (!is_array($fundingOption)) { + $this->errors[] = 'funding.'.$key.' : should be an array, '.gettype($fundingOption).' given'; + unset($this->config['funding'][$key]); + continue; + } + foreach (array('type', 'url') as $fundingData) { + if (isset($fundingOption[$fundingData]) && !is_string($fundingOption[$fundingData])) { + $this->errors[] = 'funding.'.$key.'.'.$fundingData.' : invalid value, must be a string'; + unset($this->config['funding'][$key][$fundingData]); + } + } + if (isset($fundingOption['url']) && !$this->filterUrl($fundingOption['url'])) { + $this->warnings[] = 'funding.'.$key.'.url : invalid value ('.$fundingOption['url'].'), must be an http/https URL'; + unset($this->config['funding'][$key]['url']); + } + if (empty($this->config['funding'][$key])) { + unset($this->config['funding'][$key]); + } + } + if (empty($this->config['funding'])) { + unset($this->config['funding']); + } + } + $unboundConstraint = new Constraint('=', $this->versionParser->normalize('dev-master')); $stableConstraint = new Constraint('=', '1.0.0'); diff --git a/src/Composer/Repository/Vcs/GitHubDriver.php b/src/Composer/Repository/Vcs/GitHubDriver.php index b8948c4c7..03025bfd9 100644 --- a/src/Composer/Repository/Vcs/GitHubDriver.php +++ b/src/Composer/Repository/Vcs/GitHubDriver.php @@ -35,6 +35,7 @@ class GitHubDriver extends VcsDriver protected $infoCache = array(); protected $isPrivate = false; private $isArchived = false; + private $fundingInfo; /** * Git Driver @@ -166,6 +167,10 @@ class GitHubDriver extends VcsDriver if (!isset($composer['abandoned']) && $this->isArchived) { $composer['abandoned'] = true; } + + if (!isset($composer['funding']) && $funding = $this->getFundingInfo()) { + $composer['funding'] = $funding; + } } if ($this->shouldCache($identifier)) { @@ -178,6 +183,40 @@ class GitHubDriver extends VcsDriver return $this->infoCache[$identifier]; } + private function getFundingInfo() + { + if (null !== $this->fundingInfo) { + return $this->fundingInfo; + } + + if ($this->originUrl !== 'github.com') { + return $this->fundingInfo = false; + } + + $graphql = 'query{repository(owner:"'.$this->owner.'",name:"'.$this->repository.'"){fundingLinks{platform,url}}}'; + try { + $result = $this->remoteFilesystem->getContents($this->originUrl, 'https://api.github.com/graphql', false, array( + 'http' => array( + 'method' => 'POST', + 'content' => json_encode(array('query' => $graphql)), + 'header' => array('Content-Type: application/json'), + ), + 'retry-auth-failure' => false, + )); + } catch (\TransportException $e) { + return $this->fundingInfo = false; + } + $result = json_decode($result, true); + + if (empty($result['data']['repository']['fundingLinks'])) { + return $this->fundingInfo = false; + } + + return $this->fundingInfo = array_map(function ($link) { + return array('type' => strtolower($link['platform']), 'url' => $link['url']); + }, $result['data']['repository']['fundingLinks']); + } + /** * {@inheritdoc} */ diff --git a/tests/Composer/Test/Fixtures/installer/install-funding-notice.test b/tests/Composer/Test/Fixtures/installer/install-funding-notice.test new file mode 100644 index 000000000..638a31d97 --- /dev/null +++ b/tests/Composer/Test/Fixtures/installer/install-funding-notice.test @@ -0,0 +1,54 @@ +--TEST-- +Installs a simple package with exact match requirement +--COMPOSER-- +{ + "repositories": [ + { + "type": "package", + "package": [ + { + "name": "a/a", + "version": "1.0.0", + "funding": [{ "type": "example", "url": "http://example.org/fund" }], + "require": { + "d/d": "^1.0" + } + }, + { + "name": "b/b", + "version": "1.0.0", + "funding": [{ "type": "example", "url": "http://example.org/fund" }] + }, + { + "name": "c/c", + "version": "1.0.0", + "funding": [{ "type": "example", "url": "http://example.org/fund" }] + }, + { + "name": "d/d", + "version": "1.0.0", + "require": { + "b/b": "^1.0" + } + } + ] + } + ], + "require": { + "a/a": "1.0.0" + } +} +--RUN-- +install +--EXPECT-OUTPUT-- +Loading composer repositories with package information +Updating dependencies (including require-dev) +Package operations: 3 installs, 0 updates, 0 removals +Writing lock file +Generating autoload files +2 packages you are using are looking for funding. +Use the composer fund command to find out more! +--EXPECT-- +Installing b/b (1.0.0) +Installing d/d (1.0.0) +Installing a/a (1.0.0) diff --git a/tests/Composer/Test/Json/Fixtures/composer.json b/tests/Composer/Test/Json/Fixtures/composer.json index 66e35a7e7..ec6df7f18 100644 --- a/tests/Composer/Test/Json/Fixtures/composer.json +++ b/tests/Composer/Test/Json/Fixtures/composer.json @@ -21,6 +21,12 @@ "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/composer/issues" }, + "funding": [ + { + "type": "service-subscription", + "url": "https://packagist.com" + } + ], "require": { "php": ">=5.3.2", "justinrainbow/json-schema": "~1.4", diff --git a/tests/Composer/Test/Package/Dumper/ArrayDumperTest.php b/tests/Composer/Test/Package/Dumper/ArrayDumperTest.php index 81d94bdc1..ca7d60902 100644 --- a/tests/Composer/Test/Package/Dumper/ArrayDumperTest.php +++ b/tests/Composer/Test/Package/Dumper/ArrayDumperTest.php @@ -191,6 +191,10 @@ class ArrayDumperTest extends TestCase 'support', array('foo' => 'bar'), ), + array( + 'funding', + array('type' => 'foo', 'url' => 'https://example.com'), + ), array( 'require', array(new Link('foo', 'foo/bar', new Constraint('=', '1.0.0.0'), 'requires', '1.0.0'), new Link('bar', 'bar/baz', new Constraint('=', '1.0.0.0'), 'requires', '1.0.0')), diff --git a/tests/Composer/Test/Package/Loader/ArrayLoaderTest.php b/tests/Composer/Test/Package/Loader/ArrayLoaderTest.php index 5ccaa038a..1d4f8661b 100644 --- a/tests/Composer/Test/Package/Loader/ArrayLoaderTest.php +++ b/tests/Composer/Test/Package/Loader/ArrayLoaderTest.php @@ -97,6 +97,9 @@ class ArrayLoaderTest extends TestCase 'authors' => array( array('name' => 'Bob', 'email' => 'bob@example.org', 'homepage' => 'example.org', 'role' => 'Developer'), ), + 'funding' => array( + array('type' => 'example', 'url' => 'https://example.org/fund'), + ), 'require' => array( 'foo/bar' => '1.0', ), diff --git a/tests/Composer/Test/Package/Loader/ValidatingArrayLoaderTest.php b/tests/Composer/Test/Package/Loader/ValidatingArrayLoaderTest.php index 69936f4bf..2cde001ac 100644 --- a/tests/Composer/Test/Package/Loader/ValidatingArrayLoaderTest.php +++ b/tests/Composer/Test/Package/Loader/ValidatingArrayLoaderTest.php @@ -73,6 +73,15 @@ class ValidatingArrayLoaderTest extends TestCase 'rss' => 'http://example.org/rss', 'chat' => 'http://example.org/chat', ), + 'funding' => array( + array( + 'type' => 'example', + 'url' => 'https://example.org/fund' + ), + array( + 'url' => 'https://example.org/fund' + ), + ), 'require' => array( 'a/b' => '1.*', 'b/c' => '~2',