diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml
new file mode 100644
index 0000000..ebb5767
--- /dev/null
+++ b/.github/workflows/phpstan.yml
@@ -0,0 +1,35 @@
+name: "Static analysis"
+
+on:
+ push:
+ branches:
+ - "main"
+ - "master"
+ pull_request: null
+
+jobs:
+ static-analysis:
+ runs-on: "ubuntu-latest"
+ name: "PHPStan on PHP ${{ matrix.php }}"
+ strategy:
+ fail-fast: false
+ matrix:
+ php:
+ - "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: "Install Composer dependencies"
+ uses: "ramsey/composer-install@v2"
+ with:
+ dependency-versions: "highest"
+
+ - name: "Perform static analysis"
+ run: "make phpstan"
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
new file mode 100644
index 0000000..1dce3a3
--- /dev/null
+++ b/.github/workflows/tests.yaml
@@ -0,0 +1,78 @@
+name: "Unit Tests"
+
+on:
+ push:
+ branches:
+ - "main"
+ - "master"
+ pull_request: null
+
+jobs:
+ unit-tests:
+ runs-on: "ubuntu-latest"
+ name: "Unit Tests on PHP ${{ matrix.php }} and ${{ matrix.tools }}"
+ strategy:
+ fail-fast: false
+ matrix:
+ php:
+ - "7.2"
+ - "7.3"
+ - "7.4"
+ - "8.0"
+ - "8.1"
+ tools: [ "composer" ]
+ include:
+ - php: "7.2"
+ tools: "composer:v2.0"
+
+ steps:
+ - name: "Check out repository code"
+ uses: "actions/checkout@v2"
+
+ - name: "Setup PHP"
+ uses: "shivammathur/setup-php@v2"
+ with:
+ php-version: "${{ matrix.php }}"
+ tools: "${{ matrix.tools }}"
+
+ - 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 --group default"
+
+ e2e-tests:
+ runs-on: "ubuntu-latest"
+ name: "E2E Tests on PHP ${{ matrix.php }}"
+ strategy:
+ fail-fast: false
+ matrix:
+ php:
+ - "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: "Correct bin plugin version for e2e scenarios (PR-only)"
+ if: github.event_name == 'pull_request'
+ run: find e2e -maxdepth 1 -mindepth 1 -type d -exec bash -c "cd {} && composer require --dev bamarni/composer-bin-plugin:dev-${GITHUB_SHA} --no-update" \;
+
+ - name: "Install Composer dependencies"
+ uses: "ramsey/composer-install@v2"
+ with:
+ dependency-versions: "highest"
+
+ - name: "Run tests"
+ run: "vendor/bin/phpunit --group e2e"
diff --git a/.gitignore b/.gitignore
index d45ca06..42e182a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,7 @@
-composer.lock
-vendor
-vendor-bin/*/vendor
+/composer.lock
+/dist/
+/tools/
+/vendor/
+/vendor-bin/*/vendor/
+/.phpunit.result.cache
+/.php-cs-fixer.cache
diff --git a/.makefile/touch.sh b/.makefile/touch.sh
new file mode 100755
index 0000000..5c0a775
--- /dev/null
+++ b/.makefile/touch.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+#
+# Takes a given string, e.g. 'bin/console' or 'docker-compose exec php bin/console'
+# and split it by words. For each words, if the target is a file, it is touched.
+#
+# This allows to implement a similar rule to:
+#
+# ```Makefile
+# bin/php-cs-fixer: vendor
+# touch $@
+# ```
+#
+# Indeed when the rule `bin/php-cs-fixer` is replaced with a docker-compose
+# equivalent, it will not play out as nicely.
+#
+# Arguments:
+# $1 - {string} Command potentially containing a file
+#
+
+set -Eeuo pipefail;
+
+
+readonly ERROR_COLOR="\e[41m";
+readonly NO_COLOR="\e[0m";
+
+
+if [ $# -ne 1 ]; then
+ printf "${ERROR_COLOR}Illegal number of parameters.${NO_COLOR}\n";
+
+ exit 1;
+fi
+
+
+readonly FILES="$1";
+
+
+#######################################
+# Touch the given file path if the target is a file and do not create the file
+# if does not exist.
+#
+# Globals:
+# None
+#
+# Arguments:
+# $1 - {string} File path
+#
+# Returns:
+# None
+#######################################
+touch_file() {
+ local file="$1";
+
+ if [ -e ${file} ]; then
+ touch -c ${file};
+ fi
+}
+
+for file in ${FILES}
+do
+ touch_file ${file};
+done
diff --git a/.phive/phars.xml b/.phive/phars.xml
new file mode 100644
index 0000000..335086e
--- /dev/null
+++ b/.phive/phars.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php
new file mode 100644
index 0000000..507763a
--- /dev/null
+++ b/.php-cs-fixer.php
@@ -0,0 +1,17 @@
+files()
+ ->in(['src', 'tests']);
+
+$config = new PhpCsFixer\Config();
+
+return $config
+ ->setRules([
+ '@PSR12' => true,
+ 'strict_param' => true,
+ 'array_syntax' => ['syntax' => 'short'],
+ 'no_unused_imports' => true,
+ ])
+ ->setRiskyAllowed(true)
+ ->setFinder($finder);
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index b1da73c..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,51 +0,0 @@
-language: php
-
-cache:
- directories:
- - "$HOME/.composer/cache"
-
-jobs:
- include:
- - name: PHP 5.5.9
- php: 5.5.9
- dist: trusty
- env: COMPOSER_FLAGS='--prefer-lowest'
- - name: PHP 5.5
- php: 5.5
- dist: trusty
- - name: PHP 5.6
- php: 5.6
- dist: xenial
- - name: PHP 7.0
- php: 7.0
- dist: xenial
- - name: PHP 7.1
- php: 7.1
- dist: bionic
- - name: PHP 7.2
- php: 7.2
- dist: bionic
- - name: PHP 7.3
- php: 7.3
- dist: bionic
- - name: PHP 7.4
- php: 7.4
- dist: bionic
- - name: PHP 8.0
- php: nightly
- dist: bionic
- allow_failures:
- - php: nightly
-
-before_install:
- - composer global config repositories.bin path $PWD
- - composer global require bamarni/composer-bin-plugin:dev-master
-
-install:
- - composer update --no-interaction
-
-script:
- - vendor/bin/phpunit
-
-notifications:
- email: false
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..031dfde
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,121 @@
+# See https://tech.davis-hansson.com/p/make/
+MAKEFLAGS += --warn-undefined-variables
+MAKEFLAGS += --no-builtin-rules
+
+# General variables
+TOUCH = bash .makefile/touch.sh
+
+# PHP variables
+COMPOSER=composer
+COVERAGE_DIR = dist/coverage
+INFECTION_BIN = tools/infection
+INFECTION = php -d zend.enable_gc=0 $(INFECTION_BIN) --skip-initial-tests --coverage=$(COVERAGE_DIR) --only-covered --threads=4 --min-msi=100 --min-covered-msi=100 --ansi
+PHPUNIT_BIN = vendor/bin/phpunit
+PHPUNIT = php -d zend.enable_gc=0 $(PHPUNIT_BIN)
+PHPUNIT_COVERAGE = XDEBUG_MODE=coverage $(PHPUNIT) --group default --coverage-xml=$(COVERAGE_DIR)/coverage-xml --log-junit=$(COVERAGE_DIR)/phpunit.junit.xml
+PHPSTAN_BIN = vendor/bin/phpstan
+PHPSTAN = $(PHPSTAN_BIN) analyse --level=5 src tests
+PHP_CS_FIXER_BIN = tools/php-cs-fixer
+PHP_CS_FIXER = $(PHP_CS_FIXER_BIN) fix --ansi --verbose --config=.php-cs-fixer.php
+COMPOSER_NORMALIZE_BIN=tools/composer-normalize
+COMPOSER_NORMALIZE = ./$(COMPOSER_NORMALIZE_BIN)
+
+
+.DEFAULT_GOAL := default
+
+
+#
+# Command
+#---------------------------------------------------------------------------
+
+.PHONY: help
+help: ## Shows the help
+help:
+ @printf "\033[33mUsage:\033[0m\n make TARGET\n\n\033[32m#\n# Commands\n#---------------------------------------------------------------------------\033[0m\n"
+ @fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sed -e 's/\\$$//' | sed -e 's/##//' | awk 'BEGIN {FS = ":"}; {printf "\033[33m%s:\033[0m%s\n", $$1, $$2}'
+
+
+.PHONY: default
+default: ## Runs the default task: CS fix and all the tests
+default: cs test
+
+
+.PHONY: cs
+cs: ## Runs PHP-CS-Fixer
+cs: $(PHP_CS_FIXER_BIN) $(COMPOSER_NORMALIZE_BIN)
+ $(PHP_CS_FIXER)
+ $(COMPOSER_NORMALIZE)
+
+
+.PHONY: phpstan
+phpstan: ## Runs PHPStan
+phpstan:
+ $(PHPSTAN)
+
+
+.PHONY: infection
+infection: ## Runs infection
+infection: $(INFECTION_BIN) $(COVERAGE_DIR) vendor
+ if [ -d $(COVERAGE_DIR)/coverage-xml ]; then $(INFECTION); fi
+
+
+.PHONY: test
+test: ## Runs all the tests
+test: validate-package phpstan $(COVERAGE_DIR) e2e #infection include infection later
+
+
+.PHONY: validate-package
+validate-package: ## Validates the Composer package
+validate-package: vendor
+ $(COMPOSER) validate --strict
+
+
+.PHONY: coverage
+coverage: ## Runs PHPUnit with code coverage
+coverage: $(PHPUNIT_BIN) vendor
+ $(PHPUNIT_COVERAGE)
+
+
+.PHONY: unit-test
+unit-test: ## Runs PHPUnit (default group)
+unit-test: $(PHPUNIT_BIN) vendor
+ $(PHPUNIT) --group default
+
+
+.PHONY: e2e
+e2e: ## Runs PHPUnit end-to-end tests
+e2e: $(PHPUNIT_BIN) vendor
+ $(PHPUNIT) --group e2e
+
+
+#
+# Rules
+#---------------------------------------------------------------------------
+
+# Vendor does not depend on the composer.lock since the later is not tracked
+# or committed.
+vendor: composer.json
+ $(COMPOSER) update
+ $(TOUCH) "$@"
+
+$(PHPUNIT_BIN): vendor
+ $(TOUCH) "$@"
+
+$(INFECTION_BIN): ./.phive/phars.xml
+ phive install infection
+ $(TOUCH) "$@"
+
+$(COMPOSER_NORMALIZE_BIN): ./.phive/phars.xml
+ phive install composer-normalize
+ $(TOUCH) "$@"
+
+$(COVERAGE_DIR): $(PHPUNIT_BIN) src tests phpunit.xml.dist
+ $(PHPUNIT_COVERAGE)
+ $(TOUCH) "$@"
+
+$(PHP_CS_FIXER_BIN): vendor
+ phive install php-cs-fixer
+ $(TOUCH) "$@"
+
+$(PHPSTAN_BIN): vendor
+ $(TOUCH) "$@"
diff --git a/README.md b/README.md
index 46c6349..4c8021b 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,8 @@
1. [Forward mode](#forward-mode)
1. [Reduce clutter](#reduce-clutter)
1. [Related plugins](#related-plugins)
+1. [Backward Compatibility Promise](#backward-compatibility-promise)
+1. [Contributing](#contributing)
## Why?
@@ -178,6 +180,10 @@ wish to disable that behaviour, you can do so by adding a little setting in the
}
```
+Note that otherwise, in case of conflicts (e.g. `phpstan` is present in two namespaces), only the
+first one is linked and the second one is ignored.
+
+
### 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:
@@ -206,7 +212,7 @@ There is a `forward mode` which is disabled by default. This can be activated by
}
```
-If this mode is activated, all your `composer install` and `composer update` commands are forwared to all bin directories.
+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
@@ -229,6 +235,30 @@ vendor-bin/**/composer.lock binary
* [theofidry/composer-inheritance-plugin][7]: Opinionated version of [Wikimedia composer-merge-plugin][8] to work in pair with this plugin.
+## Backward Compatibility Promise
+
+The backward compatibility promise only applies to the following API:
+
+- The commands registered by the plugin
+- The behaviour of the commands (but not their logging/output)
+- The Composer configuration
+
+The plugin implementation is considered to be strictly internal and its code may
+change at any time in a non back-ward compatible way.
+
+
+## Contributing
+
+A makefile is available to help out:
+
+```bash
+$ make # Runs all checks
+$ make help # List all available commands
+```
+
+**Note:** you do need to install [phive][phive] first.
+
+
[1]: https://github.com/etsy/phan
[2]: https://github.com/phpmetrics/PhpMetrics
[3]: https://getcomposer.org/doc/06-config.md#bin-dir
@@ -237,3 +267,5 @@ vendor-bin/**/composer.lock binary
[6]: https://github.com/nikic/PHP-Parser
[7]: https://github.com/theofidry/composer-inheritance-plugin
[8]: https://github.com/wikimedia/composer-merge-plugin
+[phive]: https://phar.io/
+[symfony-bc-policy]: https://symfony.com/doc/current/contributing/code/bc.html
diff --git a/composer.json b/composer.json
index 89752c4..06a4b5e 100644
--- a/composer.json
+++ b/composer.json
@@ -1,7 +1,8 @@
{
"name": "bamarni/composer-bin-plugin",
- "type": "composer-plugin",
"description": "No conflicts for your bin dependencies",
+ "license": "MIT",
+ "type": "composer-plugin",
"keywords": [
"composer",
"dependency",
@@ -10,20 +11,20 @@
"conflict",
"executable"
],
- "license": "MIT",
"require": {
- "php": "^5.5.9 || ^7.0 || ^8.0",
- "composer-plugin-api": "^1.0 || ^2.0"
+ "php": "^7.2.5 || ^8.0",
+ "composer-plugin-api": "^2.0"
},
"require-dev": {
- "composer/composer": "^1.0 || ^2.0",
- "symfony/console": "^2.5 || ^3.0 || ^4.0"
- },
- "config": {
- "sort-packages": true
- },
- "extra": {
- "class": "Bamarni\\Composer\\Bin\\Plugin"
+ "ext-json": "*",
+ "composer/composer": "^2.0",
+ "phpstan/extension-installer": "^1.1",
+ "phpstan/phpstan": "^1.8",
+ "phpstan/phpstan-phpunit": "^1.1",
+ "phpunit/phpunit": "^8.5 || ^9.5",
+ "symfony/console": "^5.4.7 || ^6.0.7",
+ "symfony/finder": "^5.4.7 || ^6.0.7",
+ "symfony/process": "^5.4.7 || ^6.0.7"
},
"autoload": {
"psr-4": {
@@ -35,12 +36,15 @@
"Bamarni\\Composer\\Bin\\Tests\\": "tests"
}
},
- "scripts": {
- "post-install-cmd": [
- "@composer bin phpunit install"
- ],
- "post-update-cmd": [
- "@post-install-cmd"
- ]
+ "config": {
+ "allow-plugins": {
+ "phpstan/extension-installer": true,
+ "ergebnis/composer-normalize": true,
+ "infection/extension-installer": true
+ },
+ "sort-packages": true
+ },
+ "extra": {
+ "class": "Bamarni\\Composer\\Bin\\BamarniBinPlugin"
}
}
diff --git a/e2e/scenario0/.gitignore b/e2e/scenario0/.gitignore
new file mode 100644
index 0000000..cac9c03
--- /dev/null
+++ b/e2e/scenario0/.gitignore
@@ -0,0 +1,5 @@
+/actual.txt
+/composer.lock
+/vendor/
+/vendor-bin/*/composer.lock
+/vendor-bin/*/vendor/
diff --git a/e2e/scenario0/README.md b/e2e/scenario0/README.md
new file mode 100644
index 0000000..6b2a3c9
--- /dev/null
+++ b/e2e/scenario0/README.md
@@ -0,0 +1 @@
+Regular installation on a project with multiple namespaces.
diff --git a/e2e/scenario0/composer.json b/e2e/scenario0/composer.json
new file mode 100644
index 0000000..dff4cca
--- /dev/null
+++ b/e2e/scenario0/composer.json
@@ -0,0 +1,16 @@
+{
+ "repositories": [
+ {
+ "type": "path",
+ "url": "../../"
+ }
+ ],
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "dev-master"
+ },
+ "config": {
+ "allow-plugins": {
+ "bamarni/composer-bin-plugin": true
+ }
+ }
+}
diff --git a/e2e/scenario0/expected.txt b/e2e/scenario0/expected.txt
new file mode 100644
index 0000000..a192945
--- /dev/null
+++ b/e2e/scenario0/expected.txt
@@ -0,0 +1,16 @@
+[bamarni-bin] Checking namespace vendor-bin/ns1
+Loading composer repositories with package information
+Updating dependencies
+Nothing to modify in lock file
+Writing lock file
+Installing dependencies from lock file (including require-dev)
+Nothing to install, update or remove
+Generating autoload files
+[bamarni-bin] Checking namespace vendor-bin/ns2
+Loading composer repositories with package information
+Updating dependencies
+Nothing to modify in lock file
+Writing lock file
+Installing dependencies from lock file (including require-dev)
+Nothing to install, update or remove
+Generating autoload files
diff --git a/e2e/scenario0/script.sh b/e2e/scenario0/script.sh
new file mode 100755
index 0000000..3557486
--- /dev/null
+++ b/e2e/scenario0/script.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+
+set -Eeuo pipefail
+
+# Set env envariables in order to experience a behaviour closer to what happens
+# in the CI locally. It should not hurt to set those in the CI as the CI should
+# contain those values.
+export CI=1
+export COMPOSER_NO_INTERACTION=1
+
+readonly ORIGINAL_WORKING_DIR=$(pwd)
+
+trap "cd ${ORIGINAL_WORKING_DIR}" err exit
+
+# Change to script directory
+cd "$(dirname "$0")"
+
+# Ensure we have a clean state
+rm -rf actual.txt || true
+rm -rf composer.lock || true
+rm -rf vendor || true
+rm -rf vendor-bin/*/composer.lock || true
+rm -rf vendor-bin/*/vendor || true
+
+composer update
+
+# Actual command to execute the test itself
+composer bin all update 2>&1 | tee > actual.txt
diff --git a/e2e/scenario0/vendor-bin/ns1/composer.json b/e2e/scenario0/vendor-bin/ns1/composer.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/e2e/scenario0/vendor-bin/ns1/composer.json
@@ -0,0 +1 @@
+{}
diff --git a/e2e/scenario0/vendor-bin/ns2/composer.json b/e2e/scenario0/vendor-bin/ns2/composer.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/e2e/scenario0/vendor-bin/ns2/composer.json
@@ -0,0 +1 @@
+{}
diff --git a/e2e/scenario1/.gitignore b/e2e/scenario1/.gitignore
new file mode 100644
index 0000000..cac9c03
--- /dev/null
+++ b/e2e/scenario1/.gitignore
@@ -0,0 +1,5 @@
+/actual.txt
+/composer.lock
+/vendor/
+/vendor-bin/*/composer.lock
+/vendor-bin/*/vendor/
diff --git a/e2e/scenario1/README.md b/e2e/scenario1/README.md
new file mode 100644
index 0000000..4552c26
--- /dev/null
+++ b/e2e/scenario1/README.md
@@ -0,0 +1 @@
+Regular installation on a project with multiple namespaces in verbose mode.
diff --git a/e2e/scenario1/composer.json b/e2e/scenario1/composer.json
new file mode 100644
index 0000000..dff4cca
--- /dev/null
+++ b/e2e/scenario1/composer.json
@@ -0,0 +1,16 @@
+{
+ "repositories": [
+ {
+ "type": "path",
+ "url": "../../"
+ }
+ ],
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "dev-master"
+ },
+ "config": {
+ "allow-plugins": {
+ "bamarni/composer-bin-plugin": true
+ }
+ }
+}
diff --git a/e2e/scenario1/expected.txt b/e2e/scenario1/expected.txt
new file mode 100644
index 0000000..d0dcc1c
--- /dev/null
+++ b/e2e/scenario1/expected.txt
@@ -0,0 +1,28 @@
+[bamarni-bin] Current working directory: /path/to/project/e2e/scenario1
+[bamarni-bin] Configuring bin directory to /path/to/project/e2e/scenario1/vendor/bin.
+[bamarni-bin] Checking namespace vendor-bin/ns1
+[bamarni-bin] Changed current directory to vendor-bin/ns1.
+[bamarni-bin] Running `@composer update --verbose --working-dir='.'`.
+Loading composer repositories with package information
+Updating dependencies
+Analyzed 90 packages to resolve dependencies
+Analyzed 90 rules to resolve dependencies
+Nothing to modify in lock file
+Writing lock file
+Installing dependencies from lock file (including require-dev)
+Nothing to install, update or remove
+Generating autoload files
+[bamarni-bin] Changed current directory to /path/to/project/e2e/scenario1.
+[bamarni-bin] Checking namespace vendor-bin/ns2
+[bamarni-bin] Changed current directory to vendor-bin/ns2.
+[bamarni-bin] Running `@composer update --verbose --working-dir='.'`.
+Loading composer repositories with package information
+Updating dependencies
+Analyzed 90 packages to resolve dependencies
+Analyzed 90 rules to resolve dependencies
+Nothing to modify in lock file
+Writing lock file
+Installing dependencies from lock file (including require-dev)
+Nothing to install, update or remove
+Generating autoload files
+[bamarni-bin] Changed current directory to /path/to/project/e2e/scenario1.
diff --git a/e2e/scenario1/script.sh b/e2e/scenario1/script.sh
new file mode 100755
index 0000000..575b36b
--- /dev/null
+++ b/e2e/scenario1/script.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+
+set -Eeuo pipefail
+
+# Set env envariables in order to experience a behaviour closer to what happens
+# in the CI locally. It should not hurt to set those in the CI as the CI should
+# contain those values.
+export CI=1
+export COMPOSER_NO_INTERACTION=1
+
+readonly ORIGINAL_WORKING_DIR=$(pwd)
+
+trap "cd ${ORIGINAL_WORKING_DIR}" err exit
+
+# Change to script directory
+cd "$(dirname "$0")"
+
+# Ensure we have a clean state
+rm -rf actual.txt || true
+rm -rf composer.lock || true
+rm -rf vendor || true
+rm -rf vendor-bin/*/composer.lock || true
+rm -rf vendor-bin/*/vendor || true
+
+composer update
+
+# Actual command to execute the test itself
+composer bin all update --verbose 2>&1 | tee > actual.txt
diff --git a/e2e/scenario1/vendor-bin/ns1/composer.json b/e2e/scenario1/vendor-bin/ns1/composer.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/e2e/scenario1/vendor-bin/ns1/composer.json
@@ -0,0 +1 @@
+{}
diff --git a/e2e/scenario1/vendor-bin/ns2/composer.json b/e2e/scenario1/vendor-bin/ns2/composer.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/e2e/scenario1/vendor-bin/ns2/composer.json
@@ -0,0 +1 @@
+{}
diff --git a/e2e/scenario10/.gitignore b/e2e/scenario10/.gitignore
new file mode 100644
index 0000000..1fced8e
--- /dev/null
+++ b/e2e/scenario10/.gitignore
@@ -0,0 +1,6 @@
+/actual.txt
+/.composer/
+/composer.lock
+/vendor/
+/vendor-bin/*/composer.lock
+/vendor-bin/*/vendor/
diff --git a/e2e/scenario10/README.md b/e2e/scenario10/README.md
new file mode 100644
index 0000000..0ffbf03
--- /dev/null
+++ b/e2e/scenario10/README.md
@@ -0,0 +1 @@
+Check that the composer environment variables are well respected when commands are forwarded to the namespaces.
diff --git a/e2e/scenario10/composer.json b/e2e/scenario10/composer.json
new file mode 100644
index 0000000..3061d0c
--- /dev/null
+++ b/e2e/scenario10/composer.json
@@ -0,0 +1,21 @@
+{
+ "repositories": [
+ {
+ "type": "path",
+ "url": "../../"
+ }
+ ],
+ "require": {
+ "bamarni/composer-bin-plugin": "dev-master"
+ },
+ "config": {
+ "allow-plugins": {
+ "bamarni/composer-bin-plugin": true
+ }
+ },
+ "extra": {
+ "bamarni-bin": {
+ "forward-command": true
+ }
+ }
+}
diff --git a/e2e/scenario10/expected.txt b/e2e/scenario10/expected.txt
new file mode 100644
index 0000000..033cf0b
--- /dev/null
+++ b/e2e/scenario10/expected.txt
@@ -0,0 +1,2 @@
+./.composer
+.composer
diff --git a/e2e/scenario10/script.sh b/e2e/scenario10/script.sh
new file mode 100755
index 0000000..4177416
--- /dev/null
+++ b/e2e/scenario10/script.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+
+set -Eeuo pipefail
+
+# Set env envariables in order to experience a behaviour closer to what happens
+# in the CI locally. It should not hurt to set those in the CI as the CI should
+# contain those values.
+export CI=1
+export COMPOSER_NO_INTERACTION=1
+
+readonly ORIGINAL_WORKING_DIR=$(pwd)
+
+trap "cd ${ORIGINAL_WORKING_DIR}" err exit
+
+# Change to script directory
+cd "$(dirname "$0")"
+
+# Ensure we have a clean state
+rm -rf actual.txt || true
+rm -rf .composer || true
+rm -rf composer.lock || true
+rm -rf vendor || true
+rm -rf vendor-bin/*/composer.lock || true
+rm -rf vendor-bin/*/vendor || true
+rm -rf vendor-bin/*/.composer || true
+
+readonly CUSTOM_COMPOSER_DIR=$(pwd)/.composer
+COMPOSER_CACHE_DIR=$CUSTOM_COMPOSER_DIR composer update
+
+# Actual command to execute the test itself
+find . ".composer" -name ".composer" -type d 2>&1 | sort -n | tee > actual.txt || true
diff --git a/e2e/scenario10/vendor-bin/ns1/composer.json b/e2e/scenario10/vendor-bin/ns1/composer.json
new file mode 100644
index 0000000..9871ea3
--- /dev/null
+++ b/e2e/scenario10/vendor-bin/ns1/composer.json
@@ -0,0 +1,5 @@
+{
+ "require": {
+ "nikic/iter": "v1.6.0"
+ }
+}
diff --git a/e2e/scenario11/.gitignore b/e2e/scenario11/.gitignore
new file mode 100644
index 0000000..1fced8e
--- /dev/null
+++ b/e2e/scenario11/.gitignore
@@ -0,0 +1,6 @@
+/actual.txt
+/.composer/
+/composer.lock
+/vendor/
+/vendor-bin/*/composer.lock
+/vendor-bin/*/vendor/
diff --git a/e2e/scenario11/README.md b/e2e/scenario11/README.md
new file mode 100644
index 0000000..03ab64e
--- /dev/null
+++ b/e2e/scenario11/README.md
@@ -0,0 +1 @@
+Check that the deprecation messages are well rendered.
diff --git a/e2e/scenario11/composer.json b/e2e/scenario11/composer.json
new file mode 100644
index 0000000..3061d0c
--- /dev/null
+++ b/e2e/scenario11/composer.json
@@ -0,0 +1,21 @@
+{
+ "repositories": [
+ {
+ "type": "path",
+ "url": "../../"
+ }
+ ],
+ "require": {
+ "bamarni/composer-bin-plugin": "dev-master"
+ },
+ "config": {
+ "allow-plugins": {
+ "bamarni/composer-bin-plugin": true
+ }
+ },
+ "extra": {
+ "bamarni-bin": {
+ "forward-command": true
+ }
+ }
+}
diff --git a/e2e/scenario11/expected.txt b/e2e/scenario11/expected.txt
new file mode 100644
index 0000000..0368aeb
--- /dev/null
+++ b/e2e/scenario11/expected.txt
@@ -0,0 +1,19 @@
+Loading composer repositories with package information
+Updating dependencies
+Lock file operations: 1 install, 0 updates, 0 removals
+ - Locking bamarni/composer-bin-plugin (dev-hash)
+Writing lock file
+Installing dependencies from lock file (including require-dev)
+Package operations: 1 install, 0 updates, 0 removals
+ - Installing bamarni/composer-bin-plugin (dev-hash): Symlinking from ../..
+Generating autoload files
+[bamarni-bin] The setting "bamarni-bin.bin-links" will be set to "false" from 2.x onwards. If you wish to keep it to "true", you need to set it explicitly.
+[bamarni-bin] The command is being forwarded.
+[bamarni-bin] Checking namespace vendor-bin/ns1
+Loading composer repositories with package information
+Updating dependencies
+Nothing to modify in lock file
+Writing lock file
+Installing dependencies from lock file (including require-dev)
+Nothing to install, update or remove
+Generating autoload files
diff --git a/e2e/scenario11/script.sh b/e2e/scenario11/script.sh
new file mode 100755
index 0000000..e73e86f
--- /dev/null
+++ b/e2e/scenario11/script.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+
+set -Eeuo pipefail
+
+# Set env envariables in order to experience a behaviour closer to what happens
+# in the CI locally. It should not hurt to set those in the CI as the CI should
+# contain those values.
+export CI=1
+export COMPOSER_NO_INTERACTION=1
+
+readonly ORIGINAL_WORKING_DIR=$(pwd)
+
+trap "cd ${ORIGINAL_WORKING_DIR}" err exit
+
+# Change to script directory
+cd "$(dirname "$0")"
+
+# Ensure we have a clean state
+rm -rf actual.txt || true
+rm -rf .composer || true
+rm -rf composer.lock || true
+rm -rf vendor || true
+rm -rf vendor-bin/*/composer.lock || true
+rm -rf vendor-bin/*/vendor || true
+rm -rf vendor-bin/*/.composer || true
+
+# Actual command to execute the test itself
+composer update 2>&1 | tee > actual.txt
diff --git a/e2e/scenario11/vendor-bin/ns1/composer.json b/e2e/scenario11/vendor-bin/ns1/composer.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/e2e/scenario11/vendor-bin/ns1/composer.json
@@ -0,0 +1 @@
+{}
diff --git a/e2e/scenario2/.gitignore b/e2e/scenario2/.gitignore
new file mode 100644
index 0000000..cac9c03
--- /dev/null
+++ b/e2e/scenario2/.gitignore
@@ -0,0 +1,5 @@
+/actual.txt
+/composer.lock
+/vendor/
+/vendor-bin/*/composer.lock
+/vendor-bin/*/vendor/
diff --git a/e2e/scenario2/README.md b/e2e/scenario2/README.md
new file mode 100644
index 0000000..432e6c8
--- /dev/null
+++ b/e2e/scenario2/README.md
@@ -0,0 +1 @@
+Regular script (same name) in different namespaces
diff --git a/e2e/scenario2/composer.json b/e2e/scenario2/composer.json
new file mode 100644
index 0000000..dff4cca
--- /dev/null
+++ b/e2e/scenario2/composer.json
@@ -0,0 +1,16 @@
+{
+ "repositories": [
+ {
+ "type": "path",
+ "url": "../../"
+ }
+ ],
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "dev-master"
+ },
+ "config": {
+ "allow-plugins": {
+ "bamarni/composer-bin-plugin": true
+ }
+ }
+}
diff --git a/e2e/scenario2/expected.txt b/e2e/scenario2/expected.txt
new file mode 100644
index 0000000..4fed774
--- /dev/null
+++ b/e2e/scenario2/expected.txt
@@ -0,0 +1,6 @@
+[bamarni-bin] Checking namespace vendor-bin/ns1
+> echo ns1
+ns1
+[bamarni-bin] Checking namespace vendor-bin/ns2
+> echo ns2
+ns2
diff --git a/e2e/scenario2/script.sh b/e2e/scenario2/script.sh
new file mode 100755
index 0000000..8b9f863
--- /dev/null
+++ b/e2e/scenario2/script.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+
+set -Eeuo pipefail
+
+# Set env envariables in order to experience a behaviour closer to what happens
+# in the CI locally. It should not hurt to set those in the CI as the CI should
+# contain those values.
+export CI=1
+export COMPOSER_NO_INTERACTION=1
+
+readonly ORIGINAL_WORKING_DIR=$(pwd)
+
+trap "cd ${ORIGINAL_WORKING_DIR}" err exit
+
+# Change to script directory
+cd "$(dirname "$0")"
+
+# Ensure we have a clean state
+rm -rf actual.txt || true
+rm -rf composer.lock || true
+rm -rf vendor || true
+rm -rf vendor-bin/*/composer.lock || true
+rm -rf vendor-bin/*/vendor || true
+
+composer update
+
+# Actual command to execute the test itself
+composer bin all run-script foo 2>&1 | tee > actual.txt
diff --git a/e2e/scenario2/vendor-bin/ns1/composer.json b/e2e/scenario2/vendor-bin/ns1/composer.json
new file mode 100644
index 0000000..7f276e9
--- /dev/null
+++ b/e2e/scenario2/vendor-bin/ns1/composer.json
@@ -0,0 +1,5 @@
+{
+ "scripts": {
+ "foo": "echo ns1"
+ }
+}
diff --git a/e2e/scenario2/vendor-bin/ns2/composer.json b/e2e/scenario2/vendor-bin/ns2/composer.json
new file mode 100644
index 0000000..3b8933b
--- /dev/null
+++ b/e2e/scenario2/vendor-bin/ns2/composer.json
@@ -0,0 +1,5 @@
+{
+ "scripts": {
+ "foo": "echo ns2"
+ }
+}
diff --git a/e2e/scenario3/.gitignore b/e2e/scenario3/.gitignore
new file mode 100644
index 0000000..cac9c03
--- /dev/null
+++ b/e2e/scenario3/.gitignore
@@ -0,0 +1,5 @@
+/actual.txt
+/composer.lock
+/vendor/
+/vendor-bin/*/composer.lock
+/vendor-bin/*/vendor/
diff --git a/e2e/scenario3/README.md b/e2e/scenario3/README.md
new file mode 100644
index 0000000..ad1c3b8
--- /dev/null
+++ b/e2e/scenario3/README.md
@@ -0,0 +1 @@
+Regular installation on a project with forwarded mode enabled.
diff --git a/e2e/scenario3/composer.json b/e2e/scenario3/composer.json
new file mode 100644
index 0000000..a1cc737
--- /dev/null
+++ b/e2e/scenario3/composer.json
@@ -0,0 +1,22 @@
+{
+ "repositories": [
+ {
+ "type": "path",
+ "url": "../../"
+ }
+ ],
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "dev-master"
+ },
+ "config": {
+ "allow-plugins": {
+ "bamarni/composer-bin-plugin": true
+ }
+ },
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": false,
+ "forward-command": true
+ }
+ }
+}
diff --git a/e2e/scenario3/expected.txt b/e2e/scenario3/expected.txt
new file mode 100644
index 0000000..133ffe7
--- /dev/null
+++ b/e2e/scenario3/expected.txt
@@ -0,0 +1,59 @@
+Loading composer repositories with package information
+Updating dependencies
+Analyzed 90 packages to resolve dependencies
+Analyzed 90 rules to resolve dependencies
+Lock file operations: 1 install, 0 updates, 0 removals
+Installs: bamarni/composer-bin-plugin:dev-hash
+ - Locking bamarni/composer-bin-plugin (dev-hash)
+Writing lock file
+Installing dependencies from lock file (including require-dev)
+Package operations: 1 install, 0 updates, 0 removals
+Installs: bamarni/composer-bin-plugin:dev-hash
+ - Installing bamarni/composer-bin-plugin (dev-hash): Symlinking from ../..
+Generating autoload files
+> post-autoload-dump: Bamarni\Composer\Bin\BamarniBinPlugin->onPostAutoloadDump
+[bamarni-bin] Calling onPostAutoloadDump().
+[bamarni-bin] The command is being forwarded.
+[bamarni-bin] Original input: update --verbose.
+[bamarni-bin] Current working directory: /path/to/project/e2e/scenario3
+[bamarni-bin] Checking namespace vendor-bin/ns1
+[bamarni-bin] Changed current directory to vendor-bin/ns1.
+[bamarni-bin] Running `@composer update --verbose --working-dir='.'`.
+Loading composer repositories with package information
+Updating dependencies
+Analyzed 90 packages to resolve dependencies
+Analyzed 90 rules to resolve dependencies
+Nothing to modify in lock file
+Writing lock file
+Installing dependencies from lock file (including require-dev)
+Nothing to install, update or remove
+Generating autoload files
+[bamarni-bin] Changed current directory to /path/to/project/e2e/scenario3.
+–––––––––––––––––––––
+[bamarni-bin] Calling onCommandEvent().
+[bamarni-bin] The command is being forwarded.
+[bamarni-bin] Original input: update --verbose.
+[bamarni-bin] Current working directory: /path/to/project/e2e/scenario3
+[bamarni-bin] Checking namespace vendor-bin/ns1
+[bamarni-bin] Changed current directory to vendor-bin/ns1.
+[bamarni-bin] Running `@composer update --verbose --working-dir='.'`.
+Loading composer repositories with package information
+Updating dependencies
+Analyzed 90 packages to resolve dependencies
+Analyzed 90 rules to resolve dependencies
+Nothing to modify in lock file
+Installing dependencies from lock file (including require-dev)
+Nothing to install, update or remove
+Generating autoload files
+[bamarni-bin] Changed current directory to /path/to/project/e2e/scenario3.
+Loading composer repositories with package information
+Updating dependencies
+Analyzed 90 packages to resolve dependencies
+Analyzed 90 rules to resolve dependencies
+Nothing to modify in lock file
+Installing dependencies from lock file (including require-dev)
+Nothing to install, update or remove
+Generating autoload files
+> post-autoload-dump: Bamarni\Composer\Bin\BamarniBinPlugin->onPostAutoloadDump
+[bamarni-bin] Calling onPostAutoloadDump().
+[bamarni-bin] Command already forwarded within the process: skipping.
diff --git a/e2e/scenario3/script.sh b/e2e/scenario3/script.sh
new file mode 100755
index 0000000..fa19247
--- /dev/null
+++ b/e2e/scenario3/script.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+
+set -Eeuo pipefail
+
+# Set env envariables in order to experience a behaviour closer to what happens
+# in the CI locally. It should not hurt to set those in the CI as the CI should
+# contain those values.
+export CI=1
+export COMPOSER_NO_INTERACTION=1
+
+readonly ORIGINAL_WORKING_DIR=$(pwd)
+
+trap "cd ${ORIGINAL_WORKING_DIR}" err exit
+
+# Change to script directory
+cd "$(dirname "$0")"
+
+# Ensure we have a clean state
+rm -rf actual.txt || true
+rm -rf composer.lock || true
+rm -rf vendor || true
+rm -rf vendor-bin/*/composer.lock || true
+rm -rf vendor-bin/*/vendor || true
+
+# Actual command to execute the test itself
+composer update --verbose 2>&1 | tee > actual.txt
+echo "–––––––––––––––––––––" >> actual.txt
+composer update --verbose 2>&1 | tee >> actual.txt
diff --git a/e2e/scenario3/vendor-bin/ns1/composer.json b/e2e/scenario3/vendor-bin/ns1/composer.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/e2e/scenario3/vendor-bin/ns1/composer.json
@@ -0,0 +1 @@
+{}
diff --git a/e2e/scenario5/.gitignore b/e2e/scenario5/.gitignore
new file mode 100644
index 0000000..cac9c03
--- /dev/null
+++ b/e2e/scenario5/.gitignore
@@ -0,0 +1,5 @@
+/actual.txt
+/composer.lock
+/vendor/
+/vendor-bin/*/composer.lock
+/vendor-bin/*/vendor/
diff --git a/e2e/scenario5/README.md b/e2e/scenario5/README.md
new file mode 100644
index 0000000..71f75d8
--- /dev/null
+++ b/e2e/scenario5/README.md
@@ -0,0 +1 @@
+Regular installation on a project with multiple namespaces and with links disabled.
diff --git a/e2e/scenario5/composer.json b/e2e/scenario5/composer.json
new file mode 100644
index 0000000..3d06a59
--- /dev/null
+++ b/e2e/scenario5/composer.json
@@ -0,0 +1,21 @@
+{
+ "repositories": [
+ {
+ "type": "path",
+ "url": "../../"
+ }
+ ],
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "dev-master"
+ },
+ "config": {
+ "allow-plugins": {
+ "bamarni/composer-bin-plugin": true
+ }
+ },
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": false
+ }
+ }
+}
diff --git a/e2e/scenario5/expected.txt b/e2e/scenario5/expected.txt
new file mode 100644
index 0000000..3cdb7f2
--- /dev/null
+++ b/e2e/scenario5/expected.txt
@@ -0,0 +1,5 @@
+find: vendor/bin: No such file or directory
+vendor-bin/ns1/vendor/bin/phpstan
+vendor-bin/ns1/vendor/bin/phpstan.phar
+vendor-bin/ns2/vendor/bin/phpstan
+vendor-bin/ns2/vendor/bin/phpstan.phar
diff --git a/e2e/scenario5/script.sh b/e2e/scenario5/script.sh
new file mode 100755
index 0000000..4e58fb6
--- /dev/null
+++ b/e2e/scenario5/script.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+
+set -Eeuo pipefail
+
+# Set env envariables in order to experience a behaviour closer to what happens
+# in the CI locally. It should not hurt to set those in the CI as the CI should
+# contain those values.
+export CI=1
+export COMPOSER_NO_INTERACTION=1
+
+readonly ORIGINAL_WORKING_DIR=$(pwd)
+
+trap "cd ${ORIGINAL_WORKING_DIR}" err exit
+
+# Change to script directory
+cd "$(dirname "$0")"
+
+# Ensure we have a clean state
+rm -rf actual.txt || true
+rm -rf composer.lock || true
+rm -rf vendor || true
+rm -rf vendor-bin/*/composer.lock || true
+rm -rf vendor-bin/*/vendor || true
+
+composer update
+composer bin all update
+
+# Actual command to execute the test itself
+find vendor/bin vendor-bin/*/vendor/bin -maxdepth 1 -type f 2>&1 | sort -n | tee > actual.txt || true
diff --git a/e2e/scenario5/vendor-bin/ns1/composer.json b/e2e/scenario5/vendor-bin/ns1/composer.json
new file mode 100644
index 0000000..ba924f7
--- /dev/null
+++ b/e2e/scenario5/vendor-bin/ns1/composer.json
@@ -0,0 +1,5 @@
+{
+ "require": {
+ "phpstan/phpstan": "1.8.0"
+ }
+}
diff --git a/e2e/scenario5/vendor-bin/ns2/composer.json b/e2e/scenario5/vendor-bin/ns2/composer.json
new file mode 100644
index 0000000..ba924f7
--- /dev/null
+++ b/e2e/scenario5/vendor-bin/ns2/composer.json
@@ -0,0 +1,5 @@
+{
+ "require": {
+ "phpstan/phpstan": "1.8.0"
+ }
+}
diff --git a/e2e/scenario6/.gitignore b/e2e/scenario6/.gitignore
new file mode 100644
index 0000000..cac9c03
--- /dev/null
+++ b/e2e/scenario6/.gitignore
@@ -0,0 +1,5 @@
+/actual.txt
+/composer.lock
+/vendor/
+/vendor-bin/*/composer.lock
+/vendor-bin/*/vendor/
diff --git a/e2e/scenario6/README.md b/e2e/scenario6/README.md
new file mode 100644
index 0000000..2174f99
--- /dev/null
+++ b/e2e/scenario6/README.md
@@ -0,0 +1 @@
+Regular installation on a project with multiple namespaces and with links enabled and conflicting symlinks.
diff --git a/e2e/scenario6/composer.json b/e2e/scenario6/composer.json
new file mode 100644
index 0000000..23f275b
--- /dev/null
+++ b/e2e/scenario6/composer.json
@@ -0,0 +1,21 @@
+{
+ "repositories": [
+ {
+ "type": "path",
+ "url": "../../"
+ }
+ ],
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "dev-master"
+ },
+ "config": {
+ "allow-plugins": {
+ "bamarni/composer-bin-plugin": true
+ }
+ },
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": true
+ }
+ }
+}
diff --git a/e2e/scenario6/expected.txt b/e2e/scenario6/expected.txt
new file mode 100644
index 0000000..25f159f
--- /dev/null
+++ b/e2e/scenario6/expected.txt
@@ -0,0 +1,4 @@
+find: vendor-bin/*/vendor/bin: No such file or directory
+vendor/bin/phpstan
+vendor/bin/phpstan.phar
+PHPStan - PHP Static Analysis Tool 1.8.0
diff --git a/e2e/scenario6/script.sh b/e2e/scenario6/script.sh
new file mode 100755
index 0000000..1b972f5
--- /dev/null
+++ b/e2e/scenario6/script.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+
+set -Eeuo pipefail
+
+# Set env envariables in order to experience a behaviour closer to what happens
+# in the CI locally. It should not hurt to set those in the CI as the CI should
+# contain those values.
+export CI=1
+export COMPOSER_NO_INTERACTION=1
+
+readonly ORIGINAL_WORKING_DIR=$(pwd)
+
+trap "cd ${ORIGINAL_WORKING_DIR}" err exit
+
+# Change to script directory
+cd "$(dirname "$0")"
+
+# Ensure we have a clean state
+rm -rf actual.txt || true
+rm -rf composer.lock || true
+rm -rf vendor || true
+rm -rf vendor-bin/*/composer.lock || true
+rm -rf vendor-bin/*/vendor || true
+
+composer update
+composer bin all update
+
+# Actual command to execute the test itself
+find vendor/bin vendor-bin/*/vendor/bin -maxdepth 1 -type f 2>&1 | sort -n | tee > actual.txt || true
+vendor/bin/phpstan --version >> actual.txt
diff --git a/e2e/scenario6/vendor-bin/ns1/composer.json b/e2e/scenario6/vendor-bin/ns1/composer.json
new file mode 100644
index 0000000..ba924f7
--- /dev/null
+++ b/e2e/scenario6/vendor-bin/ns1/composer.json
@@ -0,0 +1,5 @@
+{
+ "require": {
+ "phpstan/phpstan": "1.8.0"
+ }
+}
diff --git a/e2e/scenario6/vendor-bin/ns2/composer.json b/e2e/scenario6/vendor-bin/ns2/composer.json
new file mode 100644
index 0000000..7826cef
--- /dev/null
+++ b/e2e/scenario6/vendor-bin/ns2/composer.json
@@ -0,0 +1,5 @@
+{
+ "require": {
+ "phpstan/phpstan": "1.6.0"
+ }
+}
diff --git a/e2e/scenario7/.gitignore b/e2e/scenario7/.gitignore
new file mode 100644
index 0000000..cac9c03
--- /dev/null
+++ b/e2e/scenario7/.gitignore
@@ -0,0 +1,5 @@
+/actual.txt
+/composer.lock
+/vendor/
+/vendor-bin/*/composer.lock
+/vendor-bin/*/vendor/
diff --git a/e2e/scenario7/README.md b/e2e/scenario7/README.md
new file mode 100644
index 0000000..87140b4
--- /dev/null
+++ b/e2e/scenario7/README.md
@@ -0,0 +1 @@
+Tests that dev dependencies are not installed if no-dev is passed.
diff --git a/e2e/scenario7/composer.json b/e2e/scenario7/composer.json
new file mode 100644
index 0000000..bff880f
--- /dev/null
+++ b/e2e/scenario7/composer.json
@@ -0,0 +1,16 @@
+{
+ "repositories": [
+ {
+ "type": "path",
+ "url": "../../"
+ }
+ ],
+ "require": {
+ "bamarni/composer-bin-plugin": "dev-master"
+ },
+ "config": {
+ "allow-plugins": {
+ "bamarni/composer-bin-plugin": true
+ }
+ }
+}
diff --git a/e2e/scenario7/expected.txt b/e2e/scenario7/expected.txt
new file mode 100644
index 0000000..c00cece
--- /dev/null
+++ b/e2e/scenario7/expected.txt
@@ -0,0 +1,10 @@
+[bamarni-bin] Current working directory: /path/to/project/e2e/scenario7
+[bamarni-bin] Configuring bin directory to /path/to/project/e2e/scenario7/vendor/bin.
+[bamarni-bin] Checking namespace vendor-bin/ns1
+[bamarni-bin] Changed current directory to vendor-bin/ns1.
+[bamarni-bin] Running `@composer update --no-dev --verbose --working-dir='.' -- foo`.
+Cannot update only a partial set of packages without a lock file present. Run `composer update` to generate a lock file.
+[bamarni-bin] Changed current directory to /path/to/project/e2e/scenario7.
+–––––––––––––––––––––
+[bamarni-bin] Checking namespace vendor-bin/ns1
+No dependencies installed. Try running composer install or update.
diff --git a/e2e/scenario7/script.sh b/e2e/scenario7/script.sh
new file mode 100755
index 0000000..ac11060
--- /dev/null
+++ b/e2e/scenario7/script.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+
+set -Eeuo pipefail
+
+# Set env envariables in order to experience a behaviour closer to what happens
+# in the CI locally. It should not hurt to set those in the CI as the CI should
+# contain those values.
+export CI=1
+export COMPOSER_NO_INTERACTION=1
+
+readonly ORIGINAL_WORKING_DIR=$(pwd)
+
+trap "cd ${ORIGINAL_WORKING_DIR}" err exit
+
+# Change to script directory
+cd "$(dirname "$0")"
+
+# Ensure we have a clean state
+rm -rf actual.txt || true
+rm -rf composer.lock || true
+rm -rf vendor || true
+rm -rf vendor-bin/*/composer.lock || true
+rm -rf vendor-bin/*/vendor || true
+
+composer update
+
+# Actual command to execute the test itself
+composer bin ns1 update --no-dev --verbose -- foo 2>&1 | tee > actual.txt || true
+echo "–––––––––––––––––––––" >> actual.txt
+composer bin ns1 show --direct --name-only 2>&1 | tee >> actual.txt || true
diff --git a/e2e/scenario7/vendor-bin/ns1/composer.json b/e2e/scenario7/vendor-bin/ns1/composer.json
new file mode 100644
index 0000000..7b72340
--- /dev/null
+++ b/e2e/scenario7/vendor-bin/ns1/composer.json
@@ -0,0 +1,8 @@
+{
+ "require": {
+ "nikic/iter": "v1.6.0"
+ },
+ "require-dev": {
+ "phpstan/phpstan": "1.8.0"
+ }
+}
diff --git a/e2e/scenario8/.gitignore b/e2e/scenario8/.gitignore
new file mode 100644
index 0000000..cac9c03
--- /dev/null
+++ b/e2e/scenario8/.gitignore
@@ -0,0 +1,5 @@
+/actual.txt
+/composer.lock
+/vendor/
+/vendor-bin/*/composer.lock
+/vendor-bin/*/vendor/
diff --git a/e2e/scenario8/README.md b/e2e/scenario8/README.md
new file mode 100644
index 0000000..9af1b6d
--- /dev/null
+++ b/e2e/scenario8/README.md
@@ -0,0 +1 @@
+Tests that extra arguments and options are not lost when forwarding the command to a bin namespace.
diff --git a/e2e/scenario8/composer.json b/e2e/scenario8/composer.json
new file mode 100644
index 0000000..7681c07
--- /dev/null
+++ b/e2e/scenario8/composer.json
@@ -0,0 +1,22 @@
+{
+ "repositories": [
+ {
+ "type": "path",
+ "url": "../../"
+ }
+ ],
+ "require": {
+ "bamarni/composer-bin-plugin": "dev-master"
+ },
+ "config": {
+ "allow-plugins": {
+ "bamarni/composer-bin-plugin": true
+ }
+ },
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": false,
+ "forward-command": true
+ }
+ }
+}
diff --git a/e2e/scenario8/expected.txt b/e2e/scenario8/expected.txt
new file mode 100644
index 0000000..625e41f
--- /dev/null
+++ b/e2e/scenario8/expected.txt
@@ -0,0 +1,31 @@
+Loading composer repositories with package information
+Updating dependencies
+Analyzed 90 packages to resolve dependencies
+Analyzed 90 rules to resolve dependencies
+Lock file operations: 1 install, 0 updates, 0 removals
+Installs: bamarni/composer-bin-plugin:dev-hash
+ - Locking bamarni/composer-bin-plugin (dev-hash)
+Writing lock file
+Installing dependencies from lock file (including require-dev)
+Package operations: 1 install, 0 updates, 0 removals
+Installs: bamarni/composer-bin-plugin:dev-hash
+ - Installing bamarni/composer-bin-plugin (dev-hash): Symlinking from ../..
+Generating autoload files
+> post-autoload-dump: Bamarni\Composer\Bin\BamarniBinPlugin->onPostAutoloadDump
+[bamarni-bin] Calling onPostAutoloadDump().
+[bamarni-bin] The command is being forwarded.
+[bamarni-bin] Original input: update --prefer-lowest --verbose.
+[bamarni-bin] Current working directory: /path/to/project/e2e/scenario8
+[bamarni-bin] Checking namespace vendor-bin/ns1
+[bamarni-bin] Changed current directory to vendor-bin/ns1.
+[bamarni-bin] Running `@composer update --prefer-lowest --verbose --working-dir='.'`.
+Loading composer repositories with package information
+Updating dependencies
+Analyzed 90 packages to resolve dependencies
+Analyzed 90 rules to resolve dependencies
+Nothing to modify in lock file
+Writing lock file
+Installing dependencies from lock file (including require-dev)
+Nothing to install, update or remove
+Generating autoload files
+[bamarni-bin] Changed current directory to /path/to/project/e2e/scenario8.
diff --git a/e2e/scenario8/script.sh b/e2e/scenario8/script.sh
new file mode 100755
index 0000000..93a8c2a
--- /dev/null
+++ b/e2e/scenario8/script.sh
@@ -0,0 +1,26 @@
+#!/usr/bin/env bash
+
+set -Eeuo pipefail
+
+# Set env envariables in order to experience a behaviour closer to what happens
+# in the CI locally. It should not hurt to set those in the CI as the CI should
+# contain those values.
+export CI=1
+export COMPOSER_NO_INTERACTION=1
+
+readonly ORIGINAL_WORKING_DIR=$(pwd)
+
+trap "cd ${ORIGINAL_WORKING_DIR}" err exit
+
+# Change to script directory
+cd "$(dirname "$0")"
+
+# Ensure we have a clean state
+rm -rf actual.txt || true
+rm -rf composer.lock || true
+rm -rf vendor || true
+rm -rf vendor-bin/*/composer.lock || true
+rm -rf vendor-bin/*/vendor || true
+
+# Actual command to execute the test itself
+composer update --prefer-lowest --verbose 2>&1 | tee >> actual.txt || true
diff --git a/e2e/scenario8/vendor-bin/ns1/composer.json b/e2e/scenario8/vendor-bin/ns1/composer.json
new file mode 100644
index 0000000..0967ef4
--- /dev/null
+++ b/e2e/scenario8/vendor-bin/ns1/composer.json
@@ -0,0 +1 @@
+{}
diff --git a/e2e/scenario9/.gitignore b/e2e/scenario9/.gitignore
new file mode 100644
index 0000000..cac9c03
--- /dev/null
+++ b/e2e/scenario9/.gitignore
@@ -0,0 +1,5 @@
+/actual.txt
+/composer.lock
+/vendor/
+/vendor-bin/*/composer.lock
+/vendor-bin/*/vendor/
diff --git a/e2e/scenario9/README.md b/e2e/scenario9/README.md
new file mode 100644
index 0000000..80f120b
--- /dev/null
+++ b/e2e/scenario9/README.md
@@ -0,0 +1 @@
+Tests that plugins installed in a namespace are loaded when a command is executed in the namespace.
diff --git a/e2e/scenario9/composer.json b/e2e/scenario9/composer.json
new file mode 100644
index 0000000..3061d0c
--- /dev/null
+++ b/e2e/scenario9/composer.json
@@ -0,0 +1,21 @@
+{
+ "repositories": [
+ {
+ "type": "path",
+ "url": "../../"
+ }
+ ],
+ "require": {
+ "bamarni/composer-bin-plugin": "dev-master"
+ },
+ "config": {
+ "allow-plugins": {
+ "bamarni/composer-bin-plugin": true
+ }
+ },
+ "extra": {
+ "bamarni-bin": {
+ "forward-command": true
+ }
+ }
+}
diff --git a/e2e/scenario9/expected.txt b/e2e/scenario9/expected.txt
new file mode 100644
index 0000000..0515c41
--- /dev/null
+++ b/e2e/scenario9/expected.txt
@@ -0,0 +1,9 @@
+[bamarni-bin] Checking namespace vendor-bin/composer-unused
+
+Loading packages
+----------------
+
+ ! [NOTE] Found 0 package(s) to be checked.
+
+ [OK] Done. No required packages to scan.
+
diff --git a/e2e/scenario9/script.sh b/e2e/scenario9/script.sh
new file mode 100755
index 0000000..4decef4
--- /dev/null
+++ b/e2e/scenario9/script.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+
+set -Eeuo pipefail
+
+# Set env envariables in order to experience a behaviour closer to what happens
+# in the CI locally. It should not hurt to set those in the CI as the CI should
+# contain those values.
+export CI=1
+export COMPOSER_NO_INTERACTION=1
+
+readonly ORIGINAL_WORKING_DIR=$(pwd)
+
+trap "cd ${ORIGINAL_WORKING_DIR}" err exit
+
+# Change to script directory
+cd "$(dirname "$0")"
+
+# Ensure we have a clean state
+rm -rf actual.txt || true
+rm -rf composer.lock || true
+rm -rf vendor || true
+rm -rf vendor-bin/*/composer.lock || true
+rm -rf vendor-bin/*/vendor || true
+
+composer update
+
+# Actual command to execute the test itself
+composer bin composer-unused unused --no-progress 2>&1 | tee > actual.txt || true
diff --git a/e2e/scenario9/vendor-bin/composer-unused/.gitignore b/e2e/scenario9/vendor-bin/composer-unused/.gitignore
new file mode 100644
index 0000000..57494c2
--- /dev/null
+++ b/e2e/scenario9/vendor-bin/composer-unused/.gitignore
@@ -0,0 +1,2 @@
+/composer-unused-dump-*
+
diff --git a/e2e/scenario9/vendor-bin/composer-unused/composer.json b/e2e/scenario9/vendor-bin/composer-unused/composer.json
new file mode 100644
index 0000000..3c5a312
--- /dev/null
+++ b/e2e/scenario9/vendor-bin/composer-unused/composer.json
@@ -0,0 +1,11 @@
+{
+ "require": {
+ "composer-runtime-api": "^2.2",
+ "icanhazstring/composer-unused": "~0.7.12"
+ },
+ "config": {
+ "allow-plugins": {
+ "icanhazstring/composer-unused": true
+ }
+ }
+}
diff --git a/infection.json b/infection.json
new file mode 100644
index 0000000..4db6121
--- /dev/null
+++ b/infection.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "vendor/infection/infection/resources/schema.json",
+ "source": {
+ "directories": [
+ "src"
+ ]
+ },
+ "logs": {
+ "text": "dist/infection.txt"
+ },
+ "mutators": {
+ "@default": true
+ }
+}
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 9a40036..6d57a8f 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -1,6 +1,7 @@
-
-
- ./tests/
+ tests
-
\ No newline at end of file
+
+
+ src
+
+
+
+
diff --git a/src/ApplicationFactory/FreshInstanceApplicationFactory.php b/src/ApplicationFactory/FreshInstanceApplicationFactory.php
new file mode 100644
index 0000000..a4dcd58
--- /dev/null
+++ b/src/ApplicationFactory/FreshInstanceApplicationFactory.php
@@ -0,0 +1,15 @@
+composer = $composer;
+ $this->io = $io;
+ $this->logger = new Logger($io);
+ }
+
+ public function getCapabilities(): array
+ {
+ return [
+ ComposerPluginCommandProvider::class => BamarniCommandProvider::class,
+ ];
+ }
+
+ public function deactivate(Composer $composer, IOInterface $io): void
+ {
+ }
+
+ public function uninstall(Composer $composer, IOInterface $io): void
+ {
+ }
+
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ PluginEvents::COMMAND => 'onCommandEvent',
+ ScriptEvents::POST_AUTOLOAD_DUMP => 'onPostAutoloadDump',
+ ];
+ }
+
+ public function onPostAutoloadDump(Event $event): void
+ {
+ $this->logger->logDebug('Calling onPostAutoloadDump().');
+
+ $eventIO = $event->getIO();
+
+ if (!($eventIO instanceof ConsoleIO)) {
+ return;
+ }
+
+ // This is a bit convoluted but Event does not expose the input unlike
+ // CommandEvent.
+ $publicIO = PublicIO::fromConsoleIO($eventIO);
+ $eventInput = $publicIO->getInput();
+
+ $this->onEvent(
+ $eventInput->getArgument('command'),
+ $eventInput,
+ $publicIO->getOutput()
+ );
+ }
+
+ public function onCommandEvent(CommandEvent $event): bool
+ {
+ $this->logger->logDebug('Calling onCommandEvent().');
+
+ return $this->onEvent(
+ $event->getCommandName(),
+ $event->getInput(),
+ $event->getOutput()
+ );
+ }
+
+ private function onEvent(
+ string $commandName,
+ InputInterface $input,
+ OutputInterface $output
+ ): bool {
+ $config = Config::fromComposer($this->composer);
+
+ $deprecations = $config->getDeprecations();
+
+ if (count($deprecations) > 0) {
+ foreach ($deprecations as $deprecation) {
+ $this->logger->logStandard($deprecation);
+ }
+ }
+
+ if ($config->isCommandForwarded()
+ && in_array($commandName, self::FORWARDED_COMMANDS, true)
+ ) {
+ return $this->onForwardedCommand($input, $output);
+ }
+
+ return true;
+ }
+
+ protected function onForwardedCommand(
+ InputInterface $input,
+ OutputInterface $output
+ ): bool {
+ if ($this->forwarded) {
+ $this->logger->logDebug('Command already forwarded within the process: skipping.');
+
+ return true;
+ }
+
+ $this->forwarded = true;
+
+ $this->logger->logStandard('The command is being forwarded.');
+ $this->logger->logDebug(
+ sprintf(
+ 'Original input: %s.',
+ $input->__toString()
+ )
+ );
+
+ // Note that the input & output of $io should be the same as the event
+ // input & output.
+ $io = $this->io;
+
+ $application = new Application();
+
+ $command = new BinCommand();
+ $command->setComposer($this->composer);
+ $command->setApplication($application);
+ $command->setIO($io);
+
+ $forwardedCommandInput = BinInputFactory::createForwardedCommandInput($input);
+
+ try {
+ return Command::SUCCESS === $command->run(
+ $forwardedCommandInput,
+ $output
+ );
+ } catch (Throwable $throwable) {
+ return false;
+ }
+ }
+}
diff --git a/src/BinCommand.php b/src/BinCommand.php
deleted file mode 100644
index e7fd540..0000000
--- a/src/BinCommand.php
+++ /dev/null
@@ -1,186 +0,0 @@
-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 $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;
- }
-}
diff --git a/src/Command/BinCommand.php b/src/Command/BinCommand.php
new file mode 100644
index 0000000..348a04a
--- /dev/null
+++ b/src/Command/BinCommand.php
@@ -0,0 +1,325 @@
+applicationFactory = $applicationFactory ?? new FreshInstanceApplicationFactory();
+ $this->logger = $logger ?? new Logger(new NullIO());
+ }
+
+ protected function configure(): void
+ {
+ $this
+ ->setDescription('Run a command inside a bin namespace')
+ ->addArgument(
+ self::NAMESPACE_ARG,
+ InputArgument::REQUIRED
+ )
+ ->ignoreValidationErrors();
+ }
+
+ public function setIO(IOInterface $io): void
+ {
+ parent::setIO($io);
+
+ $this->logger = new Logger($io);
+ }
+
+ public function getIO(): IOInterface
+ {
+ $io = parent::getIO();
+
+ $this->logger = new Logger($io);
+
+ return $io;
+ }
+
+ public function isProxyCommand(): bool
+ {
+ return true;
+ }
+
+ public function execute(InputInterface $input, OutputInterface $output): int
+ {
+ // Switch to requireComposer() once Composer 2.3 is set as the minimum
+ $config = Config::fromComposer($this->getComposer());
+ $currentWorkingDir = getcwd();
+
+ $this->logger->logDebug(
+ sprintf(
+ 'Current working directory: %s',
+ $currentWorkingDir
+ )
+ );
+
+ // Ensures Composer is reset – we are setting some environment variables
+ // & co. so a fresh Composer instance is required.
+ $this->resetComposers();
+
+ $this->configureBinLinksDir($config);
+
+ $vendorRoot = $config->getTargetDirectory();
+ $namespace = $input->getArgument(self::NAMESPACE_ARG);
+
+ $binInput = BinInputFactory::createInput(
+ $namespace,
+ $input
+ );
+
+ return (self::ALL_NAMESPACES !== $namespace)
+ ? $this->executeInNamespace(
+ $currentWorkingDir,
+ $vendorRoot.'/'.$namespace,
+ $binInput,
+ $output
+ )
+ : $this->executeAllNamespaces(
+ $currentWorkingDir,
+ $vendorRoot,
+ $binInput,
+ $output
+ );
+ }
+
+ /**
+ * @return list
+ */
+ private static function getBinNamespaces(string $binVendorRoot): array
+ {
+ return glob($binVendorRoot.'/*', GLOB_ONLYDIR);
+ }
+
+ private function executeAllNamespaces(
+ string $originalWorkingDir,
+ string $binVendorRoot,
+ InputInterface $input,
+ OutputInterface $output
+ ): int {
+ $namespaces = self::getBinNamespaces($binVendorRoot);
+
+ if (count($namespaces) === 0) {
+ $this->logger->logStandard('Could not find any bin namespace.');
+
+ // Is a valid scenario: the user may not have set up any bin
+ // namespace yet
+ return self::SUCCESS;
+ }
+
+ $exitCode = self::SUCCESS;
+
+ foreach ($namespaces as $namespace) {
+ $exitCode += $this->executeInNamespace(
+ $originalWorkingDir,
+ $namespace,
+ $input,
+ $output
+ );
+ }
+
+ return min($exitCode, self::FAILURE);
+ }
+
+ private function executeInNamespace(
+ string $originalWorkingDir,
+ string $namespace,
+ InputInterface $input,
+ OutputInterface $output
+ ): int {
+ $this->logger->logStandard(
+ sprintf(
+ 'Checking namespace %s',
+ $namespace
+ )
+ );
+
+ try {
+ self::createNamespaceDirIfDoesNotExist($namespace);
+ } catch (CouldNotCreateNamespaceDir $exception) {
+ $this->logger->logStandard(
+ sprintf(
+ '%s',
+ $exception->getMessage()
+ )
+ );
+
+ return self::FAILURE;
+ }
+
+ // Use a new application: this avoids a variety of issues:
+ // - A command may be added in a namespace which may cause side effects
+ // when executed in another namespace afterwards (since it is the same
+ // process).
+ // - Different plugins may be registered in the namespace in which case
+ // an already executed application will not pick that up.
+ $namespaceApplication = $this->applicationFactory->create(
+ $this->getApplication()
+ );
+
+ // It is important to clean up the state either for follow-up plugins
+ // or for example the execution in the next namespace.
+ $cleanUp = function () use ($originalWorkingDir): void {
+ $this->chdir($originalWorkingDir);
+ $this->resetComposers();
+ };
+
+ $this->chdir($namespace);
+
+ $this->ensureComposerFileExists();
+
+ $namespaceInput = BinInputFactory::createNamespaceInput($input);
+
+ $this->logger->logDebug(
+ sprintf(
+ 'Running `@composer %s`.',
+ $namespaceInput->__toString()
+ )
+ );
+
+ try {
+ $exitCode = $namespaceApplication->doRun($namespaceInput, $output);
+ } catch (Throwable $executionFailed) {
+ // Ensure we do the cleanup even in case of failure
+ $cleanUp();
+
+ throw $executionFailed;
+ }
+
+ $cleanUp();
+
+ return $exitCode;
+ }
+
+ /**
+ * @throws CouldNotCreateNamespaceDir
+ */
+ private static function createNamespaceDirIfDoesNotExist(string $namespace): void
+ {
+ if (file_exists($namespace)) {
+ return;
+ }
+
+ $mkdirResult = mkdir($namespace, 0777, true);
+
+ if (!$mkdirResult && !is_dir($namespace)) {
+ throw CouldNotCreateNamespaceDir::forNamespace($namespace);
+ }
+ }
+
+ private function configureBinLinksDir(Config $config): void
+ {
+ if (!$config->binLinksAreEnabled()) {
+ return;
+ }
+
+ $binDir = ConfigFactory::createConfig()->get('bin-dir');
+
+ putenv(
+ sprintf(
+ 'COMPOSER_BIN_DIR=%s',
+ $binDir
+ )
+ );
+
+ $this->logger->logDebug(
+ sprintf(
+ 'Configuring bin directory to %s.',
+ $binDir
+ )
+ );
+ }
+
+ private function ensureComposerFileExists(): void
+ {
+ // Some plugins require access to the Composer file e.g. Symfony Flex
+ $namespaceComposerFile = Factory::getComposerFile();
+
+ if (file_exists($namespaceComposerFile)) {
+ return;
+ }
+
+ file_put_contents($namespaceComposerFile, '{}');
+
+ $this->logger->logDebug(
+ sprintf(
+ 'Created the file %s.',
+ $namespaceComposerFile
+ )
+ );
+ }
+
+ private function resetComposers(): void
+ {
+ $this->getApplication()->resetComposer();
+
+ foreach ($this->getApplication()->all() as $command) {
+ if ($command instanceof BaseCommand) {
+ $command->resetComposer();
+ }
+ }
+ }
+
+ private function chdir(string $dir): void
+ {
+ chdir($dir);
+
+ $this->logger->logDebug(
+ sprintf(
+ 'Changed current directory to %s.',
+ $dir
+ )
+ );
+ }
+}
diff --git a/src/Command/CouldNotCreateNamespaceDir.php b/src/Command/CouldNotCreateNamespaceDir.php
new file mode 100644
index 0000000..3314507
--- /dev/null
+++ b/src/Command/CouldNotCreateNamespaceDir.php
@@ -0,0 +1,21 @@
+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'];
- }
-}
diff --git a/src/Config/Config.php b/src/Config/Config.php
new file mode 100644
index 0000000..bf672cf
--- /dev/null
+++ b/src/Config/Config.php
@@ -0,0 +1,156 @@
+ true,
+ self::TARGET_DIRECTORY => 'vendor-bin',
+ self::FORWARD_COMMAND => false,
+ ];
+
+ /**
+ * @var bool
+ */
+ private $binLinks;
+
+ /**
+ * @var string
+ */
+ private $targetDirectory;
+
+ /**
+ * @var bool
+ */
+ private $forwardCommand;
+
+ /**
+ * @var list
+ */
+ private $deprecations = [];
+
+ /**
+ * @throws InvalidBamarniComposerExtraConfig
+ */
+ public static function fromComposer(Composer $composer): self
+ {
+ return new self($composer->getPackage()->getExtra());
+ }
+
+ /**
+ * @param mixed[] $extra
+ *
+ * @throws InvalidBamarniComposerExtraConfig
+ */
+ public function __construct(array $extra)
+ {
+ $userExtra = $extra[self::EXTRA_CONFIG_KEY] ?? [];
+
+ $config = array_merge(self::DEFAULT_CONFIG, $userExtra);
+
+ $getType = function_exists('get_debug_type') ? 'get_debug_type' : 'gettype';
+
+ $binLinks = $config[self::BIN_LINKS_ENABLED];
+
+ if (!is_bool($binLinks)) {
+ throw new InvalidBamarniComposerExtraConfig(
+ sprintf(
+ 'Expected setting "%s.%s" to be a boolean value. Got "%s".',
+ self::EXTRA_CONFIG_KEY,
+ self::BIN_LINKS_ENABLED,
+ $getType($binLinks)
+ )
+ );
+ }
+
+ $binLinksSetExplicitly = array_key_exists(self::BIN_LINKS_ENABLED, $userExtra);
+
+ if ($binLinks && !$binLinksSetExplicitly) {
+ $this->deprecations[] = sprintf(
+ 'The setting "%s.%s" will be set to "false" from 2.x onwards. If you wish to keep it to "true", you need to set it explicitly.',
+ self::EXTRA_CONFIG_KEY,
+ self::BIN_LINKS_ENABLED
+ );
+ }
+
+ $targetDirectory = $config[self::TARGET_DIRECTORY];
+
+ if (!is_string($targetDirectory)) {
+ throw new InvalidBamarniComposerExtraConfig(
+ sprintf(
+ 'Expected setting "%s.%s" to be a string. Got "%s".',
+ self::EXTRA_CONFIG_KEY,
+ self::TARGET_DIRECTORY,
+ $getType($targetDirectory)
+ )
+ );
+ }
+
+ $forwardCommand = $config[self::FORWARD_COMMAND];
+
+ if (!is_bool($forwardCommand)) {
+ throw new InvalidBamarniComposerExtraConfig(
+ sprintf(
+ 'Expected setting "%s.%s" to be a boolean value. Got "%s".',
+ self::EXTRA_CONFIG_KEY,
+ self::FORWARD_COMMAND,
+ gettype($forwardCommand)
+ )
+ );
+ }
+
+ $forwardCommandSetExplicitly = array_key_exists(self::FORWARD_COMMAND, $userExtra);
+
+ if (!$forwardCommand && !$forwardCommandSetExplicitly) {
+ $this->deprecations[] = sprintf(
+ 'The setting "%s.%s" will be set to "true" from 2.x onwards. If you wish to keep it to "false", you need to set it explicitly.',
+ self::EXTRA_CONFIG_KEY,
+ self::FORWARD_COMMAND
+ );
+ }
+
+ $this->binLinks = $binLinks;
+ $this->targetDirectory = $targetDirectory;
+ $this->forwardCommand = $forwardCommand;
+ }
+
+ public function binLinksAreEnabled(): bool
+ {
+ return $this->binLinks;
+ }
+
+ public function getTargetDirectory(): string
+ {
+ return $this->targetDirectory;
+ }
+
+ public function isCommandForwarded(): bool
+ {
+ return $this->forwardCommand;
+ }
+
+ /**
+ * @return list
+ */
+ public function getDeprecations(): array
+ {
+ return $this->deprecations;
+ }
+}
diff --git a/src/Config/ConfigFactory.php b/src/Config/ConfigFactory.php
new file mode 100644
index 0000000..b2fc1d4
--- /dev/null
+++ b/src/Config/ConfigFactory.php
@@ -0,0 +1,39 @@
+exists()) {
+ return $config;
+ }
+
+ $file->validateSchema(JsonFile::LAX_SCHEMA);
+
+ $config->merge($file->read());
+
+ return $config;
+ }
+
+ private function __construct()
+ {
+ }
+}
diff --git a/src/Config/InvalidBamarniComposerExtraConfig.php b/src/Config/InvalidBamarniComposerExtraConfig.php
new file mode 100644
index 0000000..105f9ea
--- /dev/null
+++ b/src/Config/InvalidBamarniComposerExtraConfig.php
@@ -0,0 +1,11 @@
+ `update --prefer-lowest`
+ *
+ * Note that no input definition is bound in the resulting input.
+ */
+ public static function createInput(
+ string $namespace,
+ InputInterface $previousInput
+ ): InputInterface {
+ $matchResult = preg_match(
+ sprintf(
+ '/^(?.+)?bin (?:(?.+?) )?(?:%1$s|\'%1$s\') (?.+?)(? -- .*)?$/',
+ preg_quote($namespace, '/')
+ ),
+ $previousInput->__toString(),
+ $matches
+ );
+
+ if (1 !== $matchResult) {
+ throw InvalidBinInput::forBinInput($previousInput);
+ }
+
+ $inputParts = array_filter(
+ array_map(
+ 'trim',
+ [
+ $matches['binCommand'],
+ $matches['preBinOptions2'] ?? '',
+ $matches['preBinOptions'] ?? '',
+ $matches['extraInput'] ?? '',
+ ]
+ )
+ );
+
+ // Move the options present _before_ bin namespaceName to after, but
+ // before the end of option marker (--) if present.
+ $reorderedInput = implode(' ', $inputParts);
+
+ return new StringInput($reorderedInput);
+ }
+
+ public static function createNamespaceInput(InputInterface $previousInput): InputInterface
+ {
+ $matchResult = preg_match(
+ '/^(.+?\s?)(--(?: .+)?)?$/',
+ $previousInput->__toString(),
+ $matches
+ );
+
+ if (1 !== $matchResult) {
+ throw InvalidBinInput::forNamespaceInput($previousInput);
+ }
+
+ $inputParts = array_filter(
+ array_map(
+ 'trim',
+ [
+ $matches[1],
+ '--working-dir=.',
+ $matches[2] ?? '',
+ ]
+ )
+ );
+
+ $newInput = implode(' ', $inputParts);
+
+ return new StringInput($newInput);
+ }
+
+ public static function createForwardedCommandInput(InputInterface $input): InputInterface
+ {
+ return new StringInput(
+ sprintf(
+ 'bin all %s',
+ $input->__toString()
+ )
+ );
+ }
+
+ private function __construct()
+ {
+ }
+}
diff --git a/src/Input/InvalidBinInput.php b/src/Input/InvalidBinInput.php
new file mode 100644
index 0000000..2418d9c
--- /dev/null
+++ b/src/Input/InvalidBinInput.php
@@ -0,0 +1,32 @@
+ ", for example "bin all update --prefer-lowest".',
+ $input->__toString()
+ )
+ );
+ }
+
+ public static function forNamespaceInput(InputInterface $input): self
+ {
+ return new self(
+ sprintf(
+ 'Could not parse the input (executed within the namespace) "%s".',
+ $input->__toString()
+ )
+ );
+ }
+}
diff --git a/src/Logger.php b/src/Logger.php
new file mode 100644
index 0000000..1905719
--- /dev/null
+++ b/src/Logger.php
@@ -0,0 +1,39 @@
+io = $io;
+ }
+
+ public function logStandard(string $message): void
+ {
+ $this->log($message, false);
+ }
+
+ public function logDebug(string $message): void
+ {
+ $this->log($message, true);
+ }
+
+ private function log(string $message, bool $debug): void
+ {
+ $verbosity = $debug
+ ? IOInterface::VERBOSE
+ : IOInterface::NORMAL;
+
+ $this->io->writeError('[bamarni-bin] '.$message, true, $verbosity);
+ }
+}
diff --git a/src/Plugin.php b/src/Plugin.php
deleted file mode 100644
index 06ea90e..0000000
--- a/src/Plugin.php
+++ /dev/null
@@ -1,130 +0,0 @@
-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;
- }
-}
diff --git a/src/PublicIO.php b/src/PublicIO.php
new file mode 100644
index 0000000..6c90d84
--- /dev/null
+++ b/src/PublicIO.php
@@ -0,0 +1,31 @@
+input,
+ $io->output,
+ $io->helperSet
+ );
+ }
+
+ public function getInput(): InputInterface
+ {
+ return $this->input;
+ }
+
+ public function getOutput(): OutputInterface
+ {
+ return $this->output;
+ }
+}
diff --git a/tests/BinCommandTest.php b/tests/BinCommandTest.php
deleted file mode 100644
index 7e1320f..0000000
--- a/tests/BinCommandTest.php
+++ /dev/null
@@ -1,110 +0,0 @@
-rootDir = sys_get_temp_dir().'/'.uniqid('composer_bin_plugin_tests_');
- mkdir($this->rootDir);
- chdir($this->rootDir);
-
- file_put_contents($this->rootDir.'/composer.json', '{}');
-
- $this->application = new Application();
- $this->application->addCommands([
- new BinCommand(),
- $this->myTestCommand = new MyTestCommand($this),
- ]);
- }
-
- public function tearDown()
- {
- putenv('COMPOSER_BIN_DIR');
- $this->myTestCommand->data = [];
- }
-
- /**
- * @dataProvider namespaceProvider
- */
- public function testNamespaceCommand($input)
- {
- $input = new StringInput($input);
- $output = new NullOutput();
- $this->application->doRun($input, $output);
-
- $this->assertCount(1, $this->myTestCommand->data);
- $dataSet = array_shift($this->myTestCommand->data);
- $this->assertEquals($dataSet['bin-dir'], $this->rootDir.'/vendor/bin');
- $this->assertEquals($dataSet['cwd'], $this->rootDir.'/vendor-bin/mynamespace');
- $this->assertEquals($dataSet['vendor-dir'], $this->rootDir.'/vendor-bin/mynamespace/vendor');
- }
-
- public static function namespaceProvider()
- {
- return [
- ['bin mynamespace mytest'],
- ['bin mynamespace mytest --myoption'],
- ];
- }
-
- public function testAllNamespaceWithoutAnyNamespace()
- {
- $input = new StringInput('bin all mytest');
- $output = new NullOutput();
- $this->application->doRun($input, $output);
-
- $this->assertEmpty($this->myTestCommand->data);
- }
-
- public function testAllNamespaceCommand()
- {
- $namespaces = ['mynamespace', 'yournamespace'];
- foreach ($namespaces as $ns) {
- mkdir($this->rootDir.'/vendor-bin/'.$ns, 0777, true);
- }
-
- $input = new StringInput('bin all mytest');
- $output = new NullOutput();
- $this->application->doRun($input, $output);
-
- $this->assertCount(count($namespaces), $this->myTestCommand->data);
-
- foreach ($namespaces as $ns) {
- $dataSet = array_shift($this->myTestCommand->data);
- $this->assertEquals($dataSet['bin-dir'], $this->rootDir . '/vendor/bin');
- $this->assertEquals($dataSet['cwd'], $this->rootDir . '/vendor-bin/'.$ns);
- $this->assertEquals($dataSet['vendor-dir'], $this->rootDir . '/vendor-bin/'.$ns.'/vendor');
- }
- }
-
- public function testBinDirFromLocalConfig()
- {
- $binDir = 'bin';
- $composer = [
- 'config' => [
- 'bin-dir' => $binDir
- ]
- ];
- file_put_contents($this->rootDir.'/composer.json', json_encode($composer));
-
- $input = new StringInput('bin theirspace mytest');
- $output = new NullOutput();
- $this->application->doRun($input, $output);
-
- $this->assertCount(1, $this->myTestCommand->data);
- $dataSet = array_shift($this->myTestCommand->data);
- $this->assertEquals($dataSet['bin-dir'], $this->rootDir.'/'.$binDir);
- }
-}
diff --git a/tests/Command/BinCommandTest.php b/tests/Command/BinCommandTest.php
new file mode 100644
index 0000000..ad83072
--- /dev/null
+++ b/tests/Command/BinCommandTest.php
@@ -0,0 +1,237 @@
+previousCwd = getcwd();
+
+ $tmpDir = sys_get_temp_dir().'/composer_bin_plugin_tests';
+
+ if (!file_exists($tmpDir)) {
+ mkdir($tmpDir);
+ }
+
+ chdir($tmpDir);
+ // On OSX sys_get_temp_dir() may return a symlink
+ $tmpDirRealPath = realpath($tmpDir);
+ self::assertNotFalse($tmpDirRealPath);
+ $this->tmpDir = $tmpDirRealPath;
+
+ file_put_contents(
+ $this->tmpDir.'/composer.json',
+ '{}'
+ );
+
+ $this->testCommand = new MyTestCommand();
+
+ $this->application = new Application();
+ $this->application->addCommands([
+ new BinCommand(new ReuseApplicationFactory()),
+ $this->testCommand,
+ ]);
+ }
+
+ public function tearDown(): void
+ {
+ putenv('COMPOSER_BIN_DIR');
+
+ chdir($this->previousCwd);
+ exec('rm -rf ' . $this->tmpDir);
+
+ unset($this->application);
+ unset($this->testCommand);
+ unset($this->previousCwd);
+ unset($this->tmpDir);
+ }
+
+ /**
+ * @dataProvider namespaceProvider
+ */
+ public function test_it_can_execute_the_bin_command(
+ string $input,
+ string $expectedRelativeBinDir,
+ string $expectedRelativeCwd,
+ string $expectedRelativeVendorDir
+ ): void {
+ $input = new StringInput($input);
+ $output = new NullOutput();
+
+ $this->application->doRun($input, $output);
+
+ $this->assertHasAccessToComposer();
+ $this->assertDataSetRecordedIs(
+ $this->tmpDir.'/'.$expectedRelativeBinDir,
+ $this->tmpDir.'/'.$expectedRelativeCwd,
+ $this->tmpDir.'/'.$expectedRelativeVendorDir
+ );
+ $this->assertNoMoreDataFound();
+ }
+
+ public function test_the_all_namespace_can_be_called(): void
+ {
+ $input = new StringInput('bin all mytest');
+ $output = new NullOutput();
+
+ $this->application->doRun($input, $output);
+
+ $this->assertNoMoreDataFound();
+ }
+
+ public function test_a_command_can_be_executed_in_each_namespace_via_the_all_namespace(): void
+ {
+ $namespaces = ['namespace1', 'namespace2'];
+
+ foreach ($namespaces as $namespace) {
+ mkdir(
+ $this->tmpDir.'/vendor-bin/'.$namespace,
+ 0777,
+ true
+ );
+ }
+
+ $input = new StringInput('bin all mytest');
+ $output = new NullOutput();
+
+ $this->application->doRun($input, $output);
+
+ $this->assertHasAccessToComposer();
+
+ foreach ($namespaces as $namespace) {
+ $this->assertDataSetRecordedIs(
+ $this->tmpDir . '/vendor/bin',
+ $this->tmpDir . '/vendor-bin/'.$namespace,
+ $this->tmpDir . '/vendor-bin/'.$namespace.'/vendor'
+ );
+ }
+
+ $this->assertNoMoreDataFound();
+ }
+
+ public function test_the_bin_dir_can_be_changed(): void
+ {
+ $binDir = 'bin';
+ $composer = [
+ 'config' => [
+ 'bin-dir' => $binDir
+ ]
+ ];
+
+ file_put_contents(
+ $this->tmpDir.'/composer.json',
+ json_encode($composer)
+ );
+
+ $input = new StringInput('bin theirspace mytest');
+ $output = new NullOutput();
+
+ $this->application->doRun($input, $output);
+
+ $this->assertHasAccessToComposer();
+ $this->assertDataSetRecordedIs(
+ $this->tmpDir.'/'.$binDir,
+ $this->tmpDir.'/'.'vendor-bin/theirspace',
+ $this->tmpDir.'/'.'vendor-bin/theirspace/vendor'
+ );
+ $this->assertNoMoreDataFound();
+ }
+
+ public static function namespaceProvider(): iterable
+ {
+ yield 'execute command from namespace' => [
+ 'bin testnamespace mytest',
+ 'vendor/bin',
+ 'vendor-bin/testnamespace',
+ 'vendor-bin/testnamespace/vendor',
+ ];
+
+ yield 'execute command with options from namespace' => [
+ 'bin testnamespace mytest --myoption',
+ 'vendor/bin',
+ 'vendor-bin/testnamespace',
+ 'vendor-bin/testnamespace/vendor',
+ ];
+ }
+
+ private function assertHasAccessToComposer(): void
+ {
+ self::assertInstanceOf(
+ Composer::class,
+ $this->testCommand->composer,
+ 'Some plugins may require access to composer file e.g. Symfony Flex'
+ );
+ }
+
+ private function assertDataSetRecordedIs(
+ string $expectedBinDir,
+ string $expectedCwd,
+ string $expectedVendorDir
+ ): void {
+ $data = array_shift($this->testCommand->data);
+
+ self::assertNotNull(
+ $data,
+ 'Expected test command to contain at least one data entry'
+ );
+ self::assertSame($expectedBinDir, $data['bin-dir']);
+ self::assertSame($expectedCwd, $data['cwd']);
+ self::assertSame($expectedVendorDir, $data['vendor-dir']);
+ }
+
+ private function assertNoMoreDataFound(): void
+ {
+ $data = array_shift($this->testCommand->data);
+
+ self::assertNull(
+ $data,
+ 'Expected test command to contain not contain any more data entries.'
+ );
+ }
+}
diff --git a/tests/Config/ConfigTest.php b/tests/Config/ConfigTest.php
new file mode 100644
index 0000000..c96d0f5
--- /dev/null
+++ b/tests/Config/ConfigTest.php
@@ -0,0 +1,133 @@
+ $expectedDeprecations
+ */
+ public function test_it_can_be_instantiated(
+ array $extra,
+ bool $expectedBinLinksEnabled,
+ string $expectedTargetDirectory,
+ bool $expectedForwardCommand,
+ array $expectedDeprecations
+ ): void {
+ $config = new Config($extra);
+
+ self::assertSame($expectedBinLinksEnabled, $config->binLinksAreEnabled());
+ self::assertSame($expectedTargetDirectory, $config->getTargetDirectory());
+ self::assertSame($expectedForwardCommand, $config->isCommandForwarded());
+ self::assertSame($expectedDeprecations, $config->getDeprecations());
+ }
+
+ public static function provideExtraConfig(): iterable
+ {
+ $binLinksEnabledDeprecationMessage = 'The setting "bamarni-bin.bin-links" will be set to "false" from 2.x onwards. If you wish to keep it to "true", you need to set it explicitly.';
+ $forwardCommandDeprecationMessage = 'The setting "bamarni-bin.forward-command" will be set to "true" from 2.x onwards. If you wish to keep it to "false", you need to set it explicitly.';
+
+ yield 'default values' => [
+ [],
+ true,
+ 'vendor-bin',
+ false,
+ [
+ $binLinksEnabledDeprecationMessage,
+ $forwardCommandDeprecationMessage,
+ ],
+ ];
+
+ yield 'unknown extra entry' => [
+ ['unknown' => 'foo'],
+ true,
+ 'vendor-bin',
+ false,
+ [
+ $binLinksEnabledDeprecationMessage,
+ $forwardCommandDeprecationMessage,
+ ],
+ ];
+
+ yield 'same as default but explicit' => [
+ [
+ Config::EXTRA_CONFIG_KEY => [
+ Config::BIN_LINKS_ENABLED => true,
+ Config::FORWARD_COMMAND => false,
+ ],
+ ],
+ true,
+ 'vendor-bin',
+ false,
+ [],
+ ];
+
+ yield 'nominal' => [
+ [
+ Config::EXTRA_CONFIG_KEY => [
+ Config::BIN_LINKS_ENABLED => false,
+ Config::TARGET_DIRECTORY => 'tools',
+ Config::FORWARD_COMMAND => true,
+ ],
+ ],
+ false,
+ 'tools',
+ true,
+ [],
+ ];
+ }
+
+ /**
+ * @dataProvider provideInvalidExtraConfig
+ */
+ public function test_it_cannot_be_instantiated_with_invalid_config(
+ array $extra,
+ string $expectedMessage
+ ): void {
+ $this->expectException(InvalidBamarniComposerExtraConfig::class);
+ $this->expectExceptionMessage($expectedMessage);
+
+ new Config($extra);
+ }
+
+ public static function provideInvalidExtraConfig(): iterable
+ {
+ yield 'non bool bin links' => [
+ [
+ Config::EXTRA_CONFIG_KEY => [
+ Config::BIN_LINKS_ENABLED => 'foo',
+ ],
+ ],
+ 'Expected setting "bamarni-bin.bin-links" to be a boolean value. Got "string".',
+ ];
+
+ yield 'non string target directory' => [
+ [
+ Config::EXTRA_CONFIG_KEY => [
+ Config::TARGET_DIRECTORY => false,
+ ],
+ ],
+ 'Expected setting "bamarni-bin.target-directory" to be a string. Got "bool".',
+ ];
+
+ yield 'non bool forward command' => [
+ [
+ Config::EXTRA_CONFIG_KEY => [
+ Config::FORWARD_COMMAND => 'foo',
+ ],
+ ],
+ 'Expected setting "bamarni-bin.forward-command" to be a boolean value. Got "string".',
+ ];
+ }
+}
diff --git a/tests/EndToEndTest.php b/tests/EndToEndTest.php
new file mode 100644
index 0000000..83ca8f0
--- /dev/null
+++ b/tests/EndToEndTest.php
@@ -0,0 +1,188 @@
+run();
+
+ $standardOutput = $scenarioProcess->getOutput();
+ $errorOutput = $scenarioProcess->getErrorOutput();
+
+ $actualPath = $scenarioPath.'/actual.txt';
+
+ if (file_exists($actualPath)) {
+ $originalContent = file_get_contents($scenarioPath.'/actual.txt');
+ } else {
+ $originalContent = 'File was not created.';
+ }
+
+ $errorMessage = <<isSuccessful(),
+ $errorMessage
+ );
+
+ $actual = self::retrieveActualOutput(
+ getcwd(),
+ $originalContent
+ );
+
+ self::assertSame($expected, $actual, $errorMessage);
+ }
+
+ public static function scenarioProvider(): iterable
+ {
+ $scenarios = Finder::create()
+ ->files()
+ ->depth(1)
+ ->in(self::E2E_DIR)
+ ->name('README.md');
+
+ foreach ($scenarios as $scenario) {
+ $scenarioPath = dirname($scenario->getPathname());
+ $scenarioName = basename($scenarioPath);
+ $description = trim($scenario->getContents());
+
+ $title = sprintf(
+ '[%s] %s',
+ $scenarioName,
+ $description
+ );
+
+ yield $title => [
+ realpath($scenarioPath),
+ ];
+ }
+ }
+
+ private static function retrieveActualOutput(
+ string $cwd,
+ string $originalContent
+ ): string {
+ $normalizedContent = str_replace(
+ $cwd,
+ '/path/to/project',
+ $originalContent
+ );
+
+ // Sometimes in the CI a different log is shown, e.g. in https://github.com/bamarni/composer-bin-plugin/runs/7246889244
+ $normalizedContent = preg_replace(
+ '/> command: .+\n/',
+ '',
+ $normalizedContent
+ );
+
+ // Those values come from the expected.txt, it actually does matter how
+ // many they are at instant t.
+ $normalizedContent = preg_replace(
+ '/Analyzed (\d+) packages to resolve dependencies/',
+ 'Analyzed 90 packages to resolve dependencies',
+ $normalizedContent
+ );
+ $normalizedContent = preg_replace(
+ '/Analyzed (\d+) rules to resolve dependencies/',
+ 'Analyzed 90 rules to resolve dependencies',
+ $normalizedContent
+ );
+
+ // We are not interested in the exact version installed especially since
+ // in a PR the version will be `dev-commithash` instead of `dev-master`.
+ $normalizedContent = preg_replace(
+ '/Installs: bamarni\/composer-bin-plugin:dev-.+/',
+ 'Installs: bamarni/composer-bin-plugin:dev-hash',
+ $normalizedContent
+ );
+ $normalizedContent = preg_replace(
+ '/Locking bamarni\/composer-bin-plugin \(dev-.+\)/',
+ 'Locking bamarni/composer-bin-plugin (dev-hash)',
+ $normalizedContent
+ );
+ $normalizedContent = preg_replace(
+ '/Installing bamarni\/composer-bin-plugin \(dev-.+\): Symlinking from \.\.\/\.\./',
+ 'Installing bamarni/composer-bin-plugin (dev-hash): Symlinking from ../..',
+ $normalizedContent
+ );
+
+ // We are not interested in the time taken which can vary from locally
+ // and on the CI.
+ // Also since the place at which this line may change depending on where
+ // it is run (i.e. is not deterministic), we simply remove it.
+ $normalizedContent = preg_replace(
+ '/Dependency resolution completed in \d\.\d{3} seconds\s/',
+ '',
+ $normalizedContent
+ );
+
+ // Normalize the find directory: on some versions of OSX it does not come
+ // with ticks but it does on Linux (at least Ubuntu).
+ $normalizedContent = preg_replace(
+ '/find: ‘?(.+?)’?: No such file or directory/u',
+ 'find: $1: No such file or directory',
+ $normalizedContent
+ );
+
+ return self::normalizeTrailingWhitespacesAndLineReturns($normalizedContent);
+ }
+
+ private static function normalizeTrailingWhitespacesAndLineReturns(string $value): string
+ {
+ return implode(
+ "\n",
+ array_map('rtrim', explode(PHP_EOL, $value))
+ );
+ }
+}
diff --git a/tests/Fixtures/MyTestCommand.php b/tests/Fixtures/MyTestCommand.php
index 9c9691f..8999511 100644
--- a/tests/Fixtures/MyTestCommand.php
+++ b/tests/Fixtures/MyTestCommand.php
@@ -1,7 +1,10 @@
+ */
public $data = [];
- private $assert;
-
- public function __construct(\PHPUnit_Framework_Assert $assert)
+ public function __construct()
{
- $this->assert = $assert;
-
parent::__construct('mytest');
+
$this->setDefinition([
- new InputOption('myoption', null, InputOption::VALUE_NONE),
+ new InputOption(
+ 'myoption',
+ null,
+ InputOption::VALUE_NONE
+ ),
]);
}
- public function execute(InputInterface $input, OutputInterface $output)
+ public function execute(InputInterface $input, OutputInterface $output): int
{
- $this->assert->assertInstanceOf(
- '\Composer\Composer',
- $this->getComposer(),
- "Some plugins may require access to composer file e.g. Symfony Flex"
- );
+ $this->composer = $this->tryComposer();
$factory = Factory::create(new NullIO());
$config = $factory->getConfig();
@@ -44,6 +52,7 @@ public function execute(InputInterface $input, OutputInterface $output)
$this->resetComposer();
$this->getApplication()->resetComposer();
+
+ return self::SUCCESS;
}
}
-
diff --git a/tests/Fixtures/ReuseApplicationFactory.php b/tests/Fixtures/ReuseApplicationFactory.php
new file mode 100644
index 0000000..98cf03d
--- /dev/null
+++ b/tests/Fixtures/ReuseApplicationFactory.php
@@ -0,0 +1,16 @@
+ [
+ $namespaceName,
+ new StringInput(
+ sprintf(
+ 'bin %s show',
+ $namespaceName
+ )
+ ),
+ new StringInput('show'),
+ ];
+
+ yield $labelPrefix.' namespaced command' => [
+ $namespaceName,
+ new StringInput(
+ sprintf(
+ 'bin %s check:platform',
+ $namespaceName
+ )
+ ),
+ new StringInput('check:platform'),
+ ];
+
+ yield $labelPrefix.'command with options' => [
+ $namespaceName,
+ new StringInput(
+ sprintf(
+ 'bin %s show --tree -i',
+ $namespaceName
+ )
+ ),
+ new StringInput('show --tree -i'),
+ ];
+
+ yield $labelPrefix.'command with annoyingly placed options' => [
+ $namespaceName,
+ new StringInput(
+ sprintf(
+ '--ansi bin %s -o --quiet show --tree -i',
+ $namespaceName
+ )
+ ),
+ new StringInput('-o --quiet show --tree -i --ansi'),
+ ];
+
+ yield $labelPrefix.'command with options with option separator' => [
+ $namespaceName,
+ new StringInput(
+ sprintf(
+ 'bin %s show --tree -i --',
+ $namespaceName
+ )
+ ),
+ new StringInput('show --tree -i --'),
+ ];
+
+ yield $labelPrefix.'command with options with option separator and follow up argument' => [
+ $namespaceName,
+ new StringInput(
+ sprintf(
+ 'bin %s show --tree -i -- foo',
+ $namespaceName
+ )
+ ),
+ new StringInput('show --tree -i -- foo'),
+ ];
+
+ yield $labelPrefix.'command with options with option separator and follow up option' => [
+ $namespaceName,
+ new StringInput(
+ sprintf(
+ 'bin %s show --tree -i -- --foo',
+ $namespaceName
+ )
+ ),
+ new StringInput('show --tree -i -- --foo'),
+ ];
+
+ yield $labelPrefix.'command with annoyingly placed options and option separator and follow up option' => [
+ $namespaceName,
+ new StringInput(
+ sprintf(
+ '--ansi bin %s -o --quiet show --tree -i -- --foo',
+ $namespaceName
+ )
+ ),
+ new StringInput('-o --quiet show --tree -i --ansi -- --foo'),
+ ];
+ }
+
+ // See https://github.com/bamarni/composer-bin-plugin/pull/23
+ yield [
+ 'foo-namespace',
+ new StringInput('bin --ansi foo-namespace flex:update --prefer-lowest'),
+ new StringInput('flex:update --prefer-lowest --ansi'),
+ ];
+ }
+
+ /**
+ * @dataProvider namespaceInputProvider
+ */
+ public function test_it_can_create_a_new_input_for_a_namespace(
+ InputInterface $previousInput,
+ InputInterface $expected
+ ): void {
+ $actual = BinInputFactory::createNamespaceInput($previousInput);
+
+ self::assertEquals($expected, $actual);
+ }
+
+ public static function namespaceInputProvider(): iterable
+ {
+ $namespaceNames = [
+ 'simpleNamespaceName',
+ 'composed-namespaceName',
+ 'regexLimiter/namespaceName',
+ 'all',
+ ];
+
+ yield 'simple command' => [
+ new StringInput('flex:update'),
+ new StringInput('flex:update --working-dir=.'),
+ ];
+
+ yield 'command with options' => [
+ new StringInput('flex:update --prefer-lowest -i'),
+ new StringInput('flex:update --prefer-lowest -i --working-dir=.'),
+ ];
+
+ yield 'command with annoyingly placed options' => [
+ new StringInput('-o --quiet flex:update --prefer-lowest -i'),
+ new StringInput('-o --quiet flex:update --prefer-lowest -i --working-dir=.'),
+ ];
+
+ yield 'command with options with option separator' => [
+ new StringInput('flex:update --prefer-lowest -i --'),
+ new StringInput('flex:update --prefer-lowest -i --working-dir=. --'),
+ ];
+
+ yield 'command with options with option separator and follow up argument' => [
+ new StringInput('flex:update --prefer-lowest -i -- foo'),
+ new StringInput('flex:update --prefer-lowest -i --working-dir=. -- foo'),
+ ];
+
+ yield 'command with annoyingly placed options and option separator and follow up option' => [
+ new StringInput('-o --quiet flex:update --prefer-lowest -i -- --foo'),
+ new StringInput('-o --quiet flex:update --prefer-lowest -i --working-dir=. -- --foo'),
+ ];
+ }
+
+ /**
+ * @dataProvider forwardedCommandInputProvider
+ */
+ public function test_it_can_create_a_new_input_for_forwarded_command(
+ InputInterface $previousInput,
+ InputInterface $expected
+ ): void {
+ $actual = BinInputFactory::createForwardedCommandInput($previousInput);
+
+ self::assertEquals($expected, $actual);
+ }
+
+ public static function forwardedCommandInputProvider(): iterable
+ {
+ yield [
+ new StringInput('install --verbose'),
+ new StringInput('bin all install --verbose'),
+ ];
+
+ yield [
+ new StringInput('flex:update --prefer-lowest --ansi'),
+ new StringInput('bin all flex:update --prefer-lowest --ansi'),
+ ];
+ }
+}
diff --git a/tests/LoggerTest.php b/tests/LoggerTest.php
new file mode 100644
index 0000000..0f7a64f
--- /dev/null
+++ b/tests/LoggerTest.php
@@ -0,0 +1,105 @@
+logStandard($message);
+
+ self::assertSame($expected, $io->getOutput());
+ }
+
+ public static function standardMessageProvider(): iterable
+ {
+ $notLoggedVerbosities = [
+ OutputInterface::VERBOSITY_QUIET,
+ ];
+
+ $loggedVerbosities = array_diff(
+ self::VERBOSITIES,
+ $notLoggedVerbosities
+ );
+
+ $message = 'Hello world!';
+ $expected = '[bamarni-bin] Hello world!'.PHP_EOL;
+
+ foreach ($notLoggedVerbosities as $verbosity) {
+ yield [$verbosity, $message, ''];
+ }
+
+ foreach ($loggedVerbosities as $verbosity) {
+ yield [$verbosity, $message, $expected];
+ }
+ }
+
+ /**
+ * @dataProvider standardMessageProvider
+ */
+ public function test_it_can_log_debug_messages(
+ int $verbosity,
+ string $message,
+ string $expected
+ ): void {
+ $io = new BufferIO('', $verbosity);
+ $logger = new Logger($io);
+
+ $logger->logStandard($message);
+
+ self::assertSame($expected, $io->getOutput());
+ }
+
+ public static function debugMessageProvider(): iterable
+ {
+ $notLoggedVerbosities = [
+ OutputInterface::VERBOSITY_QUIET,
+ OutputInterface::VERBOSITY_NORMAL,
+ ];
+
+ $loggedVerbosities = array_diff(
+ self::VERBOSITIES,
+ $notLoggedVerbosities
+ );
+
+ $message = 'Hello world!';
+ $expected = '[bamarni-bin] Hello world!'.PHP_EOL;
+
+ foreach ($notLoggedVerbosities as $verbosity) {
+ yield [$verbosity, $message, ''];
+ }
+
+ foreach ($loggedVerbosities as $verbosity) {
+ yield [$verbosity, $message, $expected];
+ }
+ }
+}
diff --git a/vendor-bin/phpunit/composer.json b/vendor-bin/phpunit/composer.json
deleted file mode 100644
index 4920ed5..0000000
--- a/vendor-bin/phpunit/composer.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "require": {
- "phpunit/phpunit": "^4.8||^5.0"
- }
-}