├── .gitignore ├── tests ├── __snapshots__ │ ├── OutputTest__summary_for_5_packages__1.txt │ ├── OutputTest__package_list_of_command__1.txt │ └── OutputTest__single_package_with_default_teaser__1.txt ├── PluginTest.php ├── Pest.php └── OutputTest.php ├── src ├── Command │ ├── Provider.php │ └── TreewareCommand.php ├── Output │ ├── Summary.php │ ├── PackageList.php │ └── SinglePackage.php ├── PackageStatsClient.php ├── PackageRepo.php ├── Package.php └── Plugin.php ├── phpunit.xml ├── composer.json ├── LICENSE ├── ecs.php └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /composer.lock 3 | /coverage/ 4 | /.phpunit.result.cache 5 | -------------------------------------------------------------------------------- /tests/__snapshots__/OutputTest__summary_for_5_packages__1.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 🌳 5 packages you are using with a Treeware licence 4 | 🌳 use the `composer treeware` command to find out more! 5 | -------------------------------------------------------------------------------- /src/Command/Provider.php: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | ./tests 10 | 11 | 12 | 13 | 14 | ./src 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /tests/PluginTest.php: -------------------------------------------------------------------------------- 1 | toEqual(['post-update-cmd' => 'showBanner']); 11 | }); 12 | 13 | test('initialize composer command', function () 14 | { 15 | $plugin = new \Treeware\Plant\Plugin(); 16 | expect($plugin)->toBeInstanceOf(Capable::class); 17 | expect($plugin->getCapabilities())->toEqual([ 18 | CommandProvider::class => Provider::class, 19 | ]); 20 | }); 21 | -------------------------------------------------------------------------------- /src/Output/Summary.php: -------------------------------------------------------------------------------- 1 | io = $io; 17 | } 18 | 19 | public function show(int $count): void 20 | { 21 | $this->io->write(PHP_EOL); 22 | $this->io->write( 23 | "🌳 {$count} packages you are using with a Treeware licence" 24 | ); 25 | $this->io->write('🌳 use the `composer treeware` command to find out more!'); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "treeware/plant", 3 | "type": "composer-plugin", 4 | "license": "MIT", 5 | "description": "", 6 | "require": { 7 | "composer-plugin-api": "^1.0|^2.0" 8 | }, 9 | "require-dev": { 10 | "composer/composer": "^1.0|^2.0", 11 | "symfony/console": "^5.1", 12 | "phpstan/phpstan": "^0.12", 13 | "pestphp/pest": "^1.0", 14 | "spatie/pest-plugin-snapshots": "^1.0", 15 | "symplify/easy-coding-standard": "^9.2" 16 | }, 17 | "autoload": { 18 | "psr-4": { 19 | "Treeware\\Plant\\": "src/" 20 | } 21 | }, 22 | "autoload-dev": { 23 | "psr-4": { 24 | "Tests\\": "tests/" 25 | } 26 | }, 27 | "scripts": { 28 | "stan": "phpstan analyse src --level=5", 29 | "ecs": "vendor/bin/ecs", 30 | "fix": "vendor/bin/ecs --fix", 31 | "test": "vendor/bin/pest", 32 | "test-reset": "vendor/bin/pest -d --update-snapshots" 33 | }, 34 | "extra": { 35 | "class": "Treeware\\Plant\\Plugin" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/PackageStatsClient.php: -------------------------------------------------------------------------------- 1 | [ 16 | 'timeout' => 2, 17 | 18 | ], 19 | ]); 20 | 21 | try { 22 | $response = file_get_contents( 23 | sprintf('%s?ref=%s', self::STATS_ENDPOINT, $ref), 24 | false, 25 | $context 26 | ); 27 | 28 | if ($response !== null) { 29 | $jsonObj = json_decode($response, false, 2, JSON_THROW_ON_ERROR); 30 | return $jsonObj->total; 31 | } 32 | } catch (Exception $e) { 33 | } 34 | 35 | return null; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Command/TreewareCommand.php: -------------------------------------------------------------------------------- 1 | getComposer(), new PackageStatsClient()); 21 | $packages = $repo->getTreewareWithStats(); 22 | 23 | (new PackageList($output, $packages))->show(); 24 | 25 | return self::SUCCESS; 26 | } 27 | 28 | protected function configure(): void 29 | { 30 | $this->setName('treeware'); 31 | $this->setDescription('List installed Treeware packages.'); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Treeware 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. 10 | -------------------------------------------------------------------------------- /src/Output/PackageList.php: -------------------------------------------------------------------------------- 1 | output = $output; 22 | $this->packages = $packages; 23 | } 24 | 25 | public function show(): void 26 | { 27 | foreach ($this->packages as $package) { 28 | $this->output->writeln("🌳 {$package->name}"); 29 | 30 | foreach ($package->priceGroups as $group => $price) { 31 | $this->output->writeln(" $price ($group)"); 32 | } 33 | 34 | $this->output->writeln("⤑ Donate: {$package->url}"); 35 | 36 | if ($package->treeCount > 0) { 37 | $this->output->writeln("⤑ Tree count: {$package->treeCount}"); 38 | } else { 39 | $this->output->writeln('⤑ No trees donated so far 😢'); 40 | } 41 | 42 | $this->output->write(PHP_EOL); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Output/SinglePackage.php: -------------------------------------------------------------------------------- 1 | io = $io; 23 | $this->package = $package; 24 | } 25 | 26 | public function show(): void 27 | { 28 | $headline = "Treeware licence of {$this->package->name} - {$this->package->description}"; 29 | $underline = str_repeat('-', strlen($headline)); 30 | $this->io->write(PHP_EOL); 31 | $this->io->write("🌳 $headline"); 32 | $this->io->write("🌳 $underline"); 33 | 34 | foreach ($this->package->teaser as $line) { 35 | $this->io->write("🌳 $line"); 36 | } 37 | 38 | foreach ($this->package->priceGroups as $group => $price) { 39 | $this->io->write("🌳 ⤑ $price ($group)"); 40 | } 41 | 42 | $this->io->write( 43 | "🌳 Donate using this link: {$this->package->url}" . PHP_EOL 44 | ); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | services(); 16 | 17 | $parameters = $containerConfigurator->parameters(); 18 | $parameters->set(Option::PATHS, [__DIR__ . '/src', __DIR__ . '/ecs.php']); 19 | 20 | // exclude paths with really nasty code 21 | $parameters->set(Option::SETS, [ 22 | SetList::COMMON, 23 | SetList::CLEAN_CODE, 24 | SetList::PSR_12, 25 | SetList::SYMPLIFY, 26 | SetList::NAMESPACES, 27 | ]); 28 | 29 | $services->set(LineLengthFixer::class) 30 | ->call('configure', [[ 31 | LineLengthFixer::LINE_LENGTH => 100, 32 | LineLengthFixer::INLINE_SHORT_LINES => false, 33 | ]]); 34 | 35 | $parameters->set(Option::SKIP, [ 36 | ExplicitStringVariableFixer::class => null, 37 | StandardizeHereNowDocKeywordFixer::class => null, 38 | MethodChainingNewlineFixer::class => null, 39 | ]); 40 | }; 41 | -------------------------------------------------------------------------------- /tests/Pest.php: -------------------------------------------------------------------------------- 1 | in('Feature'); 15 | 16 | /* 17 | |-------------------------------------------------------------------------- 18 | | Expectations 19 | |-------------------------------------------------------------------------- 20 | | 21 | | When you're writing tests, you often need to check that values meet certain conditions. The 22 | | "expect()" function gives you access to a set of "expectations" methods that you can use 23 | | to assert different things. Of course, you may extend the Expectation API at any time. 24 | | 25 | */ 26 | 27 | expect()->extend('toBeOne', function () { 28 | return $this->toBe(1); 29 | }); 30 | 31 | /* 32 | |-------------------------------------------------------------------------- 33 | | Functions 34 | |-------------------------------------------------------------------------- 35 | | 36 | | While Pest is very powerful out-of-the-box, you may have some testing code specific to your 37 | | project that you don't want to repeat in every file. Here you can also expose helpers as 38 | | global functions to help you to reduce the number of lines of code in your test files. 39 | | 40 | */ 41 | 42 | function something() 43 | { 44 | // .. 45 | } 46 | -------------------------------------------------------------------------------- /tests/OutputTest.php: -------------------------------------------------------------------------------- 1 | io = new \Composer\IO\ConsoleIO($in, $out, $helper); 13 | $this->out = $out; 14 | } 15 | ); 16 | 17 | 18 | test('summary for 5 packages', function () { 19 | 20 | (new Summary($this->io))->show(5); 21 | rewind($this->out->getStream()); 22 | 23 | $this->assertMatchesSnapshot(stream_get_contents($this->out->getStream())); 24 | }); 25 | 26 | 27 | test('single package with default teaser', function () { 28 | 29 | $extra = new \Treeware\Plant\Package( 30 | 'tester/toolbox', 31 | 'Cool stuff in a box' 32 | ); 33 | 34 | (new \Treeware\Plant\Output\SinglePackage($this->io, $extra))->show(); 35 | rewind($this->out->getStream()); 36 | 37 | $this->assertMatchesSnapshot(stream_get_contents($this->out->getStream())); 38 | }); 39 | 40 | 41 | test('package list of command', function () { 42 | 43 | $packages = [ 44 | new \Treeware\Plant\Package( 45 | 'tester/hot-toolbox', 46 | 'Hot stuff in a box' 47 | ), 48 | (new \Treeware\Plant\Package( 49 | 'tester/cool-toolbox', 50 | 'Cool stuff in a box' 51 | ))->setTreeCount(500) 52 | ]; 53 | 54 | (new \Treeware\Plant\Output\PackageList($this->out, $packages))->show(); 55 | rewind($this->out->getStream()); 56 | 57 | $this->assertMatchesSnapshot(stream_get_contents($this->out->getStream())); 58 | }); 59 | 60 | -------------------------------------------------------------------------------- /src/PackageRepo.php: -------------------------------------------------------------------------------- 1 | repo = $composer->getRepositoryManager()->getLocalRepository(); 23 | $this->client = $client; 24 | } 25 | 26 | /** 27 | * @return \Treeware\Plant\Package[] 28 | */ 29 | public function getTreeware() 30 | { 31 | $treeware = []; 32 | 33 | /** @var \Composer\Package\CompletePackageInterface[] $installedPackages */ 34 | $installedPackages = $this->repo->getPackages(); 35 | 36 | foreach ($installedPackages as $package) { 37 | $extra = $package->getExtra(); 38 | 39 | if (isset($extra['treeware'])) { 40 | $treeware[$package->getName()] = new Package( 41 | $package->getName(), 42 | $package->getDescription(), 43 | $extra['treeware']['priceGroups'] ?? [], 44 | $extra['treeware']['teaser'] ?? [] 45 | ); 46 | } 47 | } 48 | 49 | return $treeware; 50 | } 51 | 52 | /** 53 | * @return \Treeware\Plant\Package[] 54 | */ 55 | public function getTreewareWithStats(): array 56 | { 57 | $packages = $this->getTreeware(); 58 | 59 | foreach ($packages as $package) { 60 | $package->setTreeCount($this->client->getTreeCount($package->name)); 61 | } 62 | 63 | return $packages; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # What is this? 2 | 3 | It's a bit like [symfony/thanks](https://github.com/symfony/thanks), but it tries to tackle a bigger problem! **The climate crisis.** 4 | 5 | Open Source can have positive impact on it. With Treeware every donation is a motivation to work on Open Source code. 6 | 7 | The [Treeware idea](https://treeware.earth/about) is great, but it's not very visible. This package tries to solve it. 8 | 9 | 10 | Install 11 | --- 12 | 13 | Add this package as dependency to your package: 14 | 15 | ```sh 16 | $ composer require treeware/plant 17 | ``` 18 | 19 | Add an `extra` attribute to your package composer.json that contains at least an empty `treeware` object: 20 | 21 | ```json 22 | { 23 | "extra": { 24 | "treeware": {} 25 | } 26 | } 27 | ``` 28 | 29 | Or use this handy shortcut: 30 | 31 | ```sh 32 | composer config extra.treeware --json {} 33 | ``` 34 | 35 | To change the default output, add your own `teaser` and `priceGroup` properties: 36 | ```json 37 | { 38 | "extra": { 39 | "treeware": { 40 | "teaser": [ 41 | "Your message to the consumers of your package to convince them.", 42 | "Multiple lines are possible, but not more than 3 lines and 200 characters." 43 | ], 44 | "priceGroups": { 45 | "useful": 100, 46 | "important": 250, 47 | "critical": 500 48 | } 49 | } 50 | } 51 | } 52 | ``` 53 | 54 | 55 | 56 | ## Example output 57 | 58 | --- 59 | 60 | When others require or update your package using composer, a tiny reminder pops up. 61 | 62 | ```sh 63 | 64 | $ composer require this/fancy-package 65 | 66 | Using version dev-master for this/fancy-package 67 | ./composer.json has been updated 68 | Running composer update this/fancy-package 69 | Loading composer repositories with package information 70 | Updating dependencies 71 | Generating autoload files 72 | 73 | 74 | 🌳 Treeware licence of this/fancy-package - A cool package 75 | 🌳 ------------------------------------------------------------------- 76 | 🌳 The author of this open-source software cares about the climate crisis. 77 | 🌳 Using the software in a commercial project requires a donation: 78 | 🌳 ⤑ 100 trees ≈ $17 (useful) 79 | 🌳 ⤑ 250 trees ≈ $42 (important) 80 | 🌳 ⤑ 500 trees ≈ $84 (critical) 81 | 🌳 Donate using this link: https://plant.treeware.earth/this/fancy-package 82 | 83 | ``` 84 | 85 | 86 | -------------------------------------------------------------------------------- /src/Package.php: -------------------------------------------------------------------------------- 1 | 100, 13 | 'important' => 250, 14 | 'critical' => 500, 15 | ]; 16 | 17 | public const TEASER_DEFAULT = [ 18 | 'The author of this open-source software cares about the climate crisis.', 19 | 'Using the software in a commercial project requires a donation:', 20 | ]; 21 | 22 | /** 23 | * @var string 24 | */ 25 | public $name; 26 | 27 | /** 28 | * @var string 29 | */ 30 | public $description; 31 | 32 | /** 33 | * @var string 34 | */ 35 | public $url; 36 | 37 | /** 38 | * @var array 39 | */ 40 | public $priceGroups; 41 | 42 | /** 43 | * @var array 44 | */ 45 | public $teaser; 46 | 47 | /** 48 | * @var int|null 49 | */ 50 | public $treeCount = null; 51 | 52 | public function __construct( 53 | string $name, 54 | string $description, 55 | array $priceGroups = [], 56 | array $teaser = [] 57 | ) { 58 | $this->name = $name; 59 | $this->description = $description; 60 | $this->url = sprintf('%s/%s', self::BASE_URL, $name); 61 | $this->assignPriceGroups($priceGroups); 62 | $this->assignTeaser($teaser); 63 | } 64 | 65 | public function setTreeCount(int $trees): self 66 | { 67 | $this->treeCount = $trees; 68 | return $this; 69 | } 70 | 71 | private function assignPriceGroups($priceGroups = []): void 72 | { 73 | if (count($priceGroups) === 0) { 74 | $priceGroups = self::PRICE_DEFAULTS; 75 | } 76 | 77 | foreach ($priceGroups as $group => $trees) { 78 | // Avoid stupid input 79 | if (is_int($trees) && strlen($group) < 15) { 80 | $usd = round($trees * self::USD_PER_TREE); 81 | $this->priceGroups[$group] = sprintf('%d trees ≈ $%d', $trees, $usd); 82 | } 83 | } 84 | } 85 | 86 | private function assignTeaser($teaser = []): void 87 | { 88 | // Avoid stupid input 89 | if (count($teaser) === 0 || count($teaser) > 3) { 90 | $teaser = self::TEASER_DEFAULT; 91 | } 92 | if (strlen(implode(PHP_EOL, $teaser)) > 200) { 93 | $teaser = self::TEASER_DEFAULT; 94 | } 95 | 96 | $this->teaser = $teaser; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Plugin.php: -------------------------------------------------------------------------------- 1 | 'showBanner', 41 | ]; 42 | } 43 | 44 | /** 45 | * Register treeware command with composer 46 | */ 47 | public function getCapabilities(): array 48 | { 49 | return [ 50 | CommandProvider::class => Provider::class, 51 | ]; 52 | } 53 | 54 | /** 55 | * Initialize Composer plugin 56 | */ 57 | public function activate(Composer $composer, IOInterface $io): void 58 | { 59 | $this->composer = $composer; 60 | $this->io = $io; 61 | } 62 | 63 | public function deactivate(Composer $composer, IOInterface $io): void 64 | { 65 | // Not implemented 66 | } 67 | 68 | public function uninstall(Composer $composer, IOInterface $io): void 69 | { 70 | // Not implemented 71 | } 72 | 73 | public function showBanner(): void 74 | { 75 | // Plugin classes do not exist after removing 76 | // but Composer 1 triggers the event: stop here 77 | if (! class_exists('Treeware\Plant\PackageRepo')) { 78 | return; 79 | } 80 | 81 | $filter = $this->getFilteredPackages(); 82 | $repo = $this->packageRepo ?? new PackageRepo($this->composer, new PackageStatsClient()); 83 | $packages = $repo->getTreeware(); 84 | $count = count($packages); 85 | 86 | // No human 87 | if (! $this->io->isInteractive()) { 88 | return; 89 | } 90 | 91 | // No treeware packages 92 | if ($count === 0) { 93 | return; 94 | } 95 | 96 | // Full update: show summary 97 | if (count($filter) === 0) { 98 | (new Summary($this->io))->show($count); 99 | return; 100 | } 101 | 102 | // Filtered package(s): show full info 103 | foreach ($packages as $package) { 104 | if (in_array($package->name, $filter, true)) { 105 | (new SinglePackage($this->io, $package))->show(); 106 | } 107 | } 108 | } 109 | 110 | /** 111 | * A list of packages passed to the require or update command If the list is empty, no filter was applied (full 112 | * update) 113 | */ 114 | private function getFilteredPackages(): array 115 | { 116 | foreach (debug_backtrace() as $trace) { 117 | if (! isset($trace['object']) || ! isset($trace['args'][0])) { 118 | continue; 119 | } 120 | 121 | if (! $trace['args'][0] instanceof ArgvInput) { 122 | continue; 123 | } 124 | 125 | /** @var ArgvInput $input */ 126 | $input = $trace['args'][0]; 127 | 128 | return $input->getArgument('packages'); 129 | } 130 | 131 | return []; 132 | } 133 | } 134 | --------------------------------------------------------------------------------