.github/workflows/tests.yaml 0000644 00000002636 14261410444 0012176 0 ustar 00 name: "Tests"
on:
push:
branches:
- "main"
- "master"
pull_request: null
jobs:
tests:
runs-on: "ubuntu-latest"
name: "Test PHP ${{ matrix.php }}"
strategy:
fail-fast: false
matrix:
php:
- "7.2"
- "7.3"
- "7.4"
- "8.0"
- "8.1"
steps:
- name: "Check out repository code"
uses: "actions/checkout@v2"
- name: "Setup PHP"
uses: "shivammathur/setup-php@v2"
with:
php-version: "${{ matrix.php }}"
tools: "composer"
- name: "Configure global composer"
run: |
composer global config repositories.bin path $PWD
composer global config allow-plugins.bamarni/composer-bin-plugin true
composer global require bamarni/composer-bin-plugin:dev-${GITHUB_SHA}
- name: "Install Composer dependencies"
uses: "ramsey/composer-install@v2"
with:
dependency-versions: "highest"
- name: "Validate composer.json"
run: "composer validate --strict --no-check-lock"
- name: "Run tests"
run: "vendor/bin/phpunit"
LICENSE 0000644 00000002040 14261410444 0005545 0 ustar 00 Copyright (c) 2016 Bilal Amarni
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.
README.md 0000644 00000020423 14261410444 0006024 0 ustar 00 # Composer bin plugin — Isolate your bin dependencies
[![Package version](http://img.shields.io/packagist/v/bamarni/composer-bin-plugin.svg?style=flat-square)](https://packagist.org/packages/bamarni/composer-bin-plugin)
[![Build Status](https://img.shields.io/travis/bamarni/composer-bin-plugin.svg?branch=master&style=flat-square)](https://travis-ci.org/bamarni/composer-bin-plugin?branch=master)
[![License](https://img.shields.io/badge/license-MIT-red.svg?style=flat-square)](LICENSE)
## Table of Contents
1. [Why?](#why)
1. [How does this plugin work?](#how-does-this-plugin-work)
1. [Installation](#installation)
1. [Usage](#usage)
1. [Example](#example)
1. [The `all` bin namespace](#the-all-bin-namespace)
1. [What happens when symlink conflicts?](#what-happens-when-symlink-conflicts)
1. [Tips](#tips)
1. [Auto-installation](#auto-installation)
1. [Disable links](#disable-links)
1. [Change directory](#change-directory)
1. [Forward mode](#forward-mode)
1. [Reduce clutter](#reduce-clutter)
1. [Related plugins](#related-plugins)
1. [Contributing](#contributing)
## Why?
In PHP, with Composer, your dependencies are flattened, which might result in conflicts. Most of the time those
conflicts are legitimate and should be properly resolved. However you may have dev tools that you want to manage
via Composer for convenience, but should not influence your project dependencies or for which conflicts don't make
sense. For example: [EtsyPhan][1] and [PhpMetrics][2]. Installing one of those static analysis tools should not change
your application dependencies, neither should it be a problem to install both of them at the same time.
## How does this plugin work?
It allows you to install your *bin vendors* in isolated locations, and still link them to your
[bin-dir][3] (if you want to).
This is done by registering a `bin` command, which can be used to run Composer commands inside a namespace.
## Installation
# Globally
$ composer global require bamarni/composer-bin-plugin
# In your project
$ composer require --dev bamarni/composer-bin-plugin
## Usage
$ composer bin [namespace] [composer_command]
$ composer global bin [namespace] [composer_command]
### Example
Let's install [Behat][4] and [PhpSpec][5] inside a `bdd` bin namespace, [EtsyPhan][1] in `etsy-phan` and [PhpMetrics][2]
in `phpmetrics`:
$ composer bin bdd require behat/behat phpspec/phpspec
$ composer bin etsy-phan require etsy/phan
$ composer bin phpmetrics require phpmetrics/phpmetrics
This command creates the following directory structure :
.
├── composer.json
├── composer.lock
├── vendor/
│ └── bin
│ ├── behat -> ../../vendor-bin/bdd/vendor/behat/behat/bin/behat
│ ├── phpspec -> ../../vendor-bin/bdd/vendor/phpspec/phpspec/bin/phpspec
│ ├── phan -> ../../vendor-bin/etsy-phan/vendor/etsy/phan/phan
│ └── phpmetrics -> ../../vendor-bin/phpmetrics/vendor/phpmetrics/phpmetrics/bin/phpmetrics
└── vendor-bin/
└── bdd
│ ├── composer.json
│ ├── composer.lock
│ └── vendor/
│ ├── behat/
│ ├── phpspec/
│ └── ...
└── etsy-phan
│ ├── composer.json
│ ├── composer.lock
│ └── vendor/
│ ├── etsy/
│ └── ...
└── phpmetrics
├── composer.json
├── composer.lock
└── vendor/
├── phpmetrics/
└── ...
You can continue to run `vendor/bin/behat`, `vendor/bin/phpspec` and co. as before but they will be properly isolated.
Also, `composer.json` and `composer.lock` files in each namespace will allow you to take advantage of automated dependency
management as normally provided by Composer.
### The `all` bin namespace
The `all` bin namespace has a special meaning. It runs a command for all existing bin namespaces. For instance, the
following command would update all your bins :
$ composer bin all update
Changed current directory to vendor-bin/phpspec
Loading composer repositories with package information
Updating dependencies (including require-dev)
Nothing to install or update
Generating autoload files
Changed current directory to vendor-bin/phpunit
Loading composer repositories with package information
Updating dependencies (including require-dev)
Nothing to install or update
Generating autoload files
### What happens when symlink conflicts?
If we take the case described in the [example section](#example), there might be more binaries linked due to
the dependencies. For example [PhpMetrics][2] depends on [Nikic PHP-Parser][6] and as such you will also have `php-parse`
in `.vendor/bin/`:
.
├── composer.json
├── composer.lock
├── vendor/
│ └── bin
│ ├── phpmetrics -> ../../vendor-bin/phpmetrics/vendor/phpmetrics/phpmetrics/bin/phpmetrics
│ └── php-parse -> ../../vendor-bin/phpmetrics/vendor/nikic/PHP-Parser/bin/php-parsee
└── vendor-bin/
└── phpmetrics
├── composer.json
├── composer.lock
└── vendor/
├── phpmetrics/
├── nikic/
└── ...
But what happens if another bin-namespace has a dependency using [Nikic PHP-Parser][6]? In that situation symlinks would
collides and are not created (only the colliding ones).
## Tips
### Auto-installation
For convenience, you can add the following script in your `composer.json` :
```json
{
"scripts": {
"bin": "echo 'bin not installed'",
"post-install-cmd": ["@composer bin all install --ansi"],
"post-update-cmd": ["@composer bin all update --ansi"]
}
}
```
This makes sure all your bins are installed during `composer install` and updated during `composer update`.
### Disable links
By default, binaries of the sub namespaces are linked to the root one like described in [example](#example). If you
wish to disable that behaviour, you can do so by adding a little setting in the extra config:
```json
{
"extra": {
"bamarni-bin": {
"bin-links": false
}
}
}
```
### Change directory
By default, the packages are looked for in the `vendor-bin` directory. The location can be changed using `target-directory` value in the extra config:
```json
{
"extra": {
"bamarni-bin": {
"target-directory": "ci/vendor"
}
}
}
```
### Forward mode
There is a `forward mode` which is disabled by default. This can be activated by using the `forward-command` value in the extra config.
```json
{
"extra": {
"bamarni-bin": {
"forward-command": true
}
}
}
```
If this mode is activated, all your `composer install` and `composer update` commands are forwarded to all bin directories.
This is an replacement for the tasks shown in section [Auto-installation](#auto-installation).
### Reduce clutter
You can add following line to your `.gitignore` file in order to avoid committing dependencies of your tools.
```.gitignore
/vendor-bin/**/vendor
```
Updating each tool can create many not legible changes in `composer.lock` files. You can use `.gitattributes` file in order
to inform git that it shouldn't show diffs of `composer.lock` files.
```.gitattributes
vendor-bin/**/composer.lock binary
```
## Related plugins
* [theofidry/composer-inheritance-plugin][7]: Opinionated version of [Wikimedia composer-merge-plugin][8] to work in pair with this plugin.
## Contributing
Before getting started, you need to install the plugin globally:
```bash
$ composer global require --dev bamarni/composer-bin-plugin
$ composer install # now works
$ vendor/bin/phpunit # run the tests
```
[1]: https://github.com/etsy/phan
[2]: https://github.com/phpmetrics/PhpMetrics
[3]: https://getcomposer.org/doc/06-config.md#bin-dir
[4]: http://behat.org
[5]: http://phpspec.net
[6]: https://github.com/nikic/PHP-Parser
[7]: https://github.com/theofidry/composer-inheritance-plugin
[8]: https://github.com/wikimedia/composer-merge-plugin
composer.json 0000644 00000002212 14261410444 0007263 0 ustar 00 {
"name": "bamarni/composer-bin-plugin",
"type": "composer-plugin",
"description": "No conflicts for your bin dependencies",
"keywords": [
"composer",
"dependency",
"tool",
"isolation",
"conflict",
"executable"
],
"license": "MIT",
"require": {
"php": "^7.2.5 || ^8.0",
"composer-plugin-api": "^2.0"
},
"require-dev": {
"composer/composer": "^2.0",
"phpstan/extension-installer": "^1.1",
"symfony/console": "^5.4.7 || ^6.0.7"
},
"config": {
"sort-packages": true,
"allow-plugins": {
"phpstan/extension-installer": true
}
},
"extra": {
"class": "Bamarni\\Composer\\Bin\\Plugin"
},
"autoload": {
"psr-4": {
"Bamarni\\Composer\\Bin\\": "src"
}
},
"autoload-dev": {
"psr-4": {
"Bamarni\\Composer\\Bin\\Tests\\": "tests"
}
},
"scripts": {
"post-install-cmd": [
"@composer bin phpunit install"
],
"post-update-cmd": [
"@post-install-cmd"
]
}
}
src/BinCommand.php 0000644 00000012537 14261410444 0010063 0 ustar 00 setName('bin')
->setDescription('Run a command inside a bin namespace')
->setDefinition([
new InputArgument('namespace', InputArgument::REQUIRED),
new InputArgument('args', InputArgument::REQUIRED | InputArgument::IS_ARRAY),
])
->ignoreValidationErrors()
;
}
/**
* {@inheritDoc}
*/
public function execute(InputInterface $input, OutputInterface $output)
{
$config = new Config($this->getComposer());
$this->resetComposers($application = $this->getApplication());
/** @var ComposerApplication $application */
if ($config->binLinksAreEnabled()) {
putenv('COMPOSER_BIN_DIR='.$this->createConfig()->get('bin-dir'));
}
$vendorRoot = $config->getTargetDirectory();
$namespace = $input->getArgument('namespace');
$input = new StringInput(preg_replace(
sprintf('/bin\s+(--ansi\s)?%s(\s.+)/', preg_quote($namespace, '/')),
'$1$2',
(string) $input,
1
));
return ('all' !== $namespace)
? $this->executeInNamespace($application, $vendorRoot.'/'.$namespace, $input, $output)
: $this->executeAllNamespaces($application, $vendorRoot, $input, $output)
;
}
/**
* @param ComposerApplication $application
* @param string $binVendorRoot
* @param InputInterface $input
* @param OutputInterface $output
*
* @return int Exit code
*/
private function executeAllNamespaces(ComposerApplication $application, $binVendorRoot, InputInterface $input, OutputInterface $output)
{
$binRoots = glob($binVendorRoot.'/*', GLOB_ONLYDIR);
if (empty($binRoots)) {
$this->getIO()->writeError('Couldn\'t find any bin namespace.');
return 0; // Is a valid scenario: the user may not have setup any bin namespace yet
}
$originalWorkingDir = getcwd();
$exitCode = 0;
foreach ($binRoots as $namespace) {
$output->writeln(
sprintf('Run in namespace %s', $namespace),
OutputInterface::VERBOSITY_VERBOSE
);
$exitCode += $this->executeInNamespace($application, $namespace, $input, $output);
chdir($originalWorkingDir);
$this->resetComposers($application);
}
return min($exitCode, 255);
}
/**
* @param ComposerApplication $application
* @param string $namespace
* @param InputInterface $input
* @param OutputInterface $output
*
* @return int Exit code
*/
private function executeInNamespace(ComposerApplication $application, $namespace, InputInterface $input, OutputInterface $output)
{
if (!file_exists($namespace)) {
mkdir($namespace, 0777, true);
}
$this->chdir($namespace);
// some plugins require access to composer file e.g. Symfony Flex
if (!file_exists(Factory::getComposerFile())) {
file_put_contents(Factory::getComposerFile(), '{}');
}
$input = new StringInput((string) $input . ' --working-dir=.');
$this->getIO()->writeError(
sprintf('Run with %s', $input->__toString()),
true,
IOInterface::VERBOSE
);
return $application->doRun($input, $output);
}
/**
* {@inheritDoc}
*/
public function isProxyCommand()
{
return true;
}
/**
* Resets all Composer references in the application.
*
* @param ComposerApplication $application
* @return void
*/
private function resetComposers(ComposerApplication $application)
{
$application->resetComposer();
foreach ($this->getApplication()->all() as $command) {
if ($command instanceof BaseCommand) {
$command->resetComposer();
}
}
}
/**
* @param string $dir
* @return void
*/
private function chdir($dir)
{
chdir($dir);
$this->getIO()->writeError(
sprintf('Changed current directory to %s', $dir),
true,
IOInterface::VERBOSE
);
}
/**
* @return \Composer\Config
* @throws \Composer\Json\JsonValidationException
* @throws \Seld\JsonLint\ParsingException
*/
private function createConfig()
{
$config = Factory::createConfig();
$file = new JsonFile(Factory::getComposerFile());
if (!$file->exists()) {
return $config;
}
$file->validateSchema(JsonFile::LAX_SCHEMA);
$config->merge($file->read());
return $config;
}
}
src/CommandProvider.php 0000644 00000000451 14261410444 0011135 0 ustar 00 getPackage()->getExtra();
$this->config = array_merge(
[
'bin-links' => true,
'target-directory' => 'vendor-bin',
'forward-command' => false,
],
isset($extra['bamarni-bin']) ? $extra['bamarni-bin'] : []
);
}
/**
* @return bool
*/
public function binLinksAreEnabled()
{
return true === $this->config['bin-links'];
}
/**
* @return string
*/
public function getTargetDirectory()
{
return $this->config['target-directory'];
}
/**
* @return bool
*/
public function isCommandForwarded()
{
return $this->config['forward-command'];
}
}
src/Plugin.php 0000644 00000006204 14261410444 0007304 0 ustar 00 composer = $composer;
$this->io = $io;
}
/**
* @return string[]
*/
public function getCapabilities()
{
return [
'Composer\Plugin\Capability\CommandProvider' => 'Bamarni\Composer\Bin\CommandProvider',
];
}
/**
* @return void
*/
public function deactivate(Composer $composer, IOInterface $io)
{
}
/**
* @return void
*/
public function uninstall(Composer $composer, IOInterface $io)
{
}
/**
* @return string[]
*/
public static function getSubscribedEvents()
{
return [
PluginEvents::COMMAND => 'onCommandEvent',
];
}
/**
* @param CommandEvent $event
* @return bool
*/
public function onCommandEvent(CommandEvent $event)
{
$config = new Config($this->composer);
if ($config->isCommandForwarded()) {
switch ($event->getCommandName()) {
case 'update':
case 'install':
return $this->onCommandEventInstallUpdate($event);
}
}
return true;
}
/**
* @param CommandEvent $event
* @return bool
*/
protected function onCommandEventInstallUpdate(CommandEvent $event)
{
$command = new BinCommand();
$command->setComposer($this->composer);
$command->setApplication(new Application());
$arguments = [
'command' => $command->getName(),
'namespace' => 'all',
'args' => [],
];
foreach (array_filter($event->getInput()->getArguments()) as $argument) {
$arguments['args'][] = $argument;
}
foreach (array_keys(array_filter($event->getInput()->getOptions())) as $option) {
$arguments['args'][] = '--' . $option;
}
$definition = new InputDefinition();
$definition->addArgument(new InputArgument('command', InputArgument::REQUIRED));
$definition->addArguments($command->getDefinition()->getArguments());
$definition->addOptions($command->getDefinition()->getOptions());
$input = new ArrayInput($arguments, $definition);
try {
$returnCode = $command->run($input, $event->getOutput());
} catch (\Exception $e) {
return false;
}
return $returnCode === 0;
}
}