├── .devcontainer └── devcontainer.json ├── .github ├── actions │ ├── php-7.2 │ │ └── Dockerfile │ ├── php-7.3 │ │ └── Dockerfile │ ├── php-7.4 │ │ └── Dockerfile │ └── php-8.0 │ │ └── Dockerfile ├── dependabot.yml └── workflows │ └── testing-and-cs.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── phpunit.xml ├── src ├── Configuration.php ├── InvalidConfigException.php ├── Plugin.php ├── PushCommand.php ├── PushCommandProvider.php ├── RepositoryProvider │ ├── AbstractProvider.php │ ├── ArtifactoryProvider.php │ └── NexusProvider.php └── ZipArchiver.php └── tests ├── ConfigurationTest.php ├── RepositoryProvider ├── NexusProviderTest.php └── testFile.txt ├── ZipArchiverTest.php └── ZipArchiverTest ├── ComposerJsonArchive ├── composer.json └── src │ ├── myFile.php │ └── myOtherFile.php └── TypicalArchive ├── .foo └── foo.txt ├── README.md └── src ├── myFile.php ├── myOtherFile.php └── tests ├── myFileTest.php └── myOtherFileTest.php /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/php 3 | { 4 | "name": "PHP", 5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile 6 | "image": "mcr.microsoft.com/devcontainers/php:1-8.2-bullseye", 7 | 8 | // Features to add to the dev container. More info: https://containers.dev/features. 9 | // "features": {}, 10 | 11 | // Configure tool-specific properties. 12 | 13 | "customizations" : { 14 | "jetbrains" : { 15 | "backend" : "PhpStorm" 16 | } 17 | }, 18 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 19 | "forwardPorts": [8080], 20 | 21 | // Use 'postCreateCommand' to run commands after the container is created. 22 | // "postCreateCommand": "sudo chmod a+x \"$(pwd)\" && sudo rm -rf /var/www/html && sudo ln -s \"$(pwd)\" /var/www/html" 23 | 24 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 25 | // "remoteUser": "root" 26 | } 27 | -------------------------------------------------------------------------------- /.github/actions/php-7.2/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:7.2-cli 2 | 3 | RUN apt-get update && apt-get install -y libz-dev 4 | 5 | RUN docker-php-ext-install zip -------------------------------------------------------------------------------- /.github/actions/php-7.3/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:7.3-cli 2 | 3 | RUN apt-get update && apt-get install -y libz-dev 4 | 5 | RUN docker-php-ext-install zip 6 | -------------------------------------------------------------------------------- /.github/actions/php-7.4/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:7.4-cli 2 | 3 | RUN apt-get update && apt-get install -y libz-dev 4 | 5 | RUN docker-php-ext-install zip 6 | -------------------------------------------------------------------------------- /.github/actions/php-8.0/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.0-rc-cli 2 | 3 | RUN apt-get update && apt-get install -y libz-dev 4 | 5 | RUN docker-php-ext-install zip 6 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: composer 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 99 9 | -------------------------------------------------------------------------------- /.github/workflows/testing-and-cs.yaml: -------------------------------------------------------------------------------- 1 | name: Testing and Code Quality 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | PHPCS: 6 | name: Code Sniffing 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout repository 10 | uses: actions/checkout@v2 11 | - name: 'Composer install' 12 | uses: docker://composer:2.2 13 | with: 14 | args: install 15 | - name: 'Code sniffing' 16 | uses: docker://php:8.1-cli 17 | with: 18 | args: "vendor/bin/php-cs-fixer fix src --dry-run" 19 | 20 | PHPUnit-Symfony45: 21 | name: PHPUnit testing 22 | runs-on: ubuntu-latest 23 | strategy: 24 | fail-fast: false 25 | matrix: 26 | php: ["7.2", "7.3", "7.4", "8.0", "8.1"] 27 | composer: ["1.10", "2.0", "2.1", "2.2"] 28 | symfony: ["^4.0", "^5.0"] 29 | steps: 30 | - name: Checkout repository 31 | uses: actions/checkout@v2 32 | - name: 'Unit testing' 33 | uses: shivammathur/setup-php@v2 34 | with: 35 | php-version: ${{ matrix.php }} 36 | tools: composer:${{ matrix.composer }} 37 | - run: composer require "symfony/finder:${{ matrix.symfony }}" "symfony/filesystem:${{ matrix.symfony }}" 38 | - run: composer install 39 | - run: vendor/bin/phpunit 40 | PHPUnit-Symfony56: 41 | name: PHPUnit testing 42 | runs-on: ubuntu-latest 43 | strategy: 44 | fail-fast: false 45 | matrix: 46 | php: ["8.0", "8.1"] 47 | composer: ["2.0", "2.1", "2.2", "2.3"] 48 | symfony: ["^5.4", "^6.0"] 49 | steps: 50 | - name: Checkout repository 51 | uses: actions/checkout@v2 52 | - name: 'Unit testing' 53 | uses: shivammathur/setup-php@v2 54 | with: 55 | php-version: ${{ matrix.php }} 56 | tools: composer:${{ matrix.composer }} 57 | - run: composer require "symfony/finder:${{ matrix.symfony }}" "symfony/filesystem:${{ matrix.symfony }}" 58 | - run: composer install 59 | - run: vendor/bin/phpunit 60 | 61 | BuildDone: 62 | name: PHP full build 63 | needs: [PHPCS, PHPUnit-Symfony45, PHPUnit-Symfony56] 64 | runs-on: ubuntu-latest 65 | steps: 66 | - run: "echo build done" 67 | 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | composer.lock 2 | /vendor/ 3 | .idea/ 4 | .phpunit.result.cache 5 | .php_cs.cache 6 | .php-cs-fixer.cache -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [1.0.4] - 21.02.2023 8 | * Silence deprecation messages #70 thanks to @tm1000 9 | 10 | ## [1.0.3] - 27.06.2022 11 | * Add progress bar #66 thanks to @tm1000 12 | 13 | ## [1.0.2] - 05.04.2022 14 | * Add fixes for return code in execute command #64 15 | * Fix testing with old versions of Symfony and code styling tool 16 | 17 | ## [1.0.1] - 05.04.2022 18 | * Due to the backward compatibility issues with Composer 2.3, specify in composer.json that it's not yet supported. 19 | 20 | ## [1.0.0] - 09.02.2022 21 | * Drop support of Symfony 3 #63 thanks to @tm1000 22 | * Add support for Symfony 6 #63 thanks to @tm1000 23 | 24 | ## [0.8.1] - 30.10.2021 25 | * Throw error when the version is not specified #57 thanks to @LeJeanbono 26 | * Display the correct repository type instead of always Nexus #58 - Thanks to @hexa2k9 and @LeJeanbono 27 | 28 | ## [0.8.0] - 13.10.2021 29 | * Add support for access tokens #51 #55 - Thanks to @LeJeanbono 30 | * Use version from composer.json if none is specified in the CLI #10 #56 - Thanks to @LeJeanbono 31 | 32 | ## [0.7.0] - 21.07.2021 33 | * Rename from `elendev/nexus-composer-push` to `elendev/composer-push`. #50 34 | * Add `Apache-2.0` to `composer.json`. #50 35 | 36 | ## [0.6.1] - 21.07.2021 37 | * Add `artifactory` support by using `"type": "artyfactory"` in configuration. #49 38 | * Last version of `elendev/nexus-composer-push`, use `elendev/composer-push` instead. 39 | * Change namespace from `Elendev\NexusComposerPush` to `Elendev\ComposerPush`. #49 40 | 41 | ## [0.6.0] - 14.07.2021 42 | * `nexus-push` command is now **deprecated**, use `push` instead. 43 | * `nexus-push` configuration in the `composer.json` file is **deprecated**, use `push` instead 44 | * Support of composer `<1.10` dropped, composer versions supported: `^1.10|^2.0` 45 | * Add options to support multiple repository types. Currently only `nexus` is supported. 46 | * Add `ssl-verify` parameter in configuration 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2017 Swissquote Bank 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Push command for composer 2 | This composer plugin provide a `composer push` command that allow to push the current package into a distant composer repository. 3 | 4 | Currently supported repositories are: 5 | * [JFrog Artifactory](https://jfrog.com/artifactory/) 6 | * Nexus, with [nexus-repository-composer](https://github.com/sonatype-nexus-community/nexus-repository-composer). 7 | 8 | ## Installation 9 | ```bash 10 | $ composer require elendev/composer-push 11 | ``` 12 | 13 | **Important note** 14 | 15 | This plugin is the continuation of `elendev/nexus-composer-push`, you have to migrate to this one if you haven't done it yet. 16 | 17 | ## Usage 18 | Many of the options are optional since they can be added directly to the `composer.json` file. 19 | ```bash 20 | # At the root of your directory 21 | $ composer push [--name=] \ 22 | [--url=] \ 23 | [--type=] 24 | [--repository=] \ 25 | [--username=USERNAME] \ 26 | [--password=PASSWORD] \ 27 | [--ignore=test.php]\ 28 | [--ignore=foo/]\ 29 | [--ignore-by-git-attributes]\ 30 | [--src-type=]\ 31 | [--src-url=]\ 32 | [--src-ref=]\ 33 | [--keep-vendor, Keep vendor directory when creating zip]\ 34 | [--ssl-verify=true/]\ 35 | [--access-token=] 36 | 37 | 38 | If is not set, `composer.json` version will be used. 39 | 40 | # Example 41 | $ composer push --username=admin --password=admin123 --url=http://localhost:8081/repository/composer --ignore=test.php --ignore=foo/ --src-type=git --src-url="$(git remote get-url origin)" --src-ref="$(git rev-parse HEAD)" 0.0.1 42 | 43 | # Example of use --repository 44 | # you need firstly configure multi repositories in composer.json of the project. 45 | # Please refer to Configuration below (multi repository configuration format) for configuration method 46 | # The component will be uploaded to the first repository whose's name value matching -- repository value 47 | # If there is no matching between the value of repository name and the value of -- repository, the upload will fail with a prompt 48 | $ composer push --username=admin --password=admin123 --repository=prod --ignore=test.php --ignore=foo/ 0.0.1 49 | ``` 50 | 51 | ## Configuration 52 | It's possible to add some configurations inside the `composer.json` file 53 | ```json 54 | { 55 | "extra": { 56 | "push": { 57 | "url": "http://localhost:8081/repository/composer", 58 | "type": "nexus", 59 | "ssl-verify": true, 60 | "username": "admin", 61 | "password": "admin123", 62 | "ignore-by-git-attributes": true, 63 | "ignore": [ 64 | "test.php", 65 | "foo/" 66 | ] 67 | } 68 | } 69 | } 70 | ``` 71 | Above configuration may be called unique repository configuration format, as you can only configue one repository in composer.json. 72 | 73 | In practice, for security reasons, different versions of component code, such as production and development, often apply different deployment policy, such as disable redeploy for the production version and allow redeploy for the development version, so they need to be stored in different repositories. 74 | For versions later than 0.1.5, the command-line parameter -- repository is introduced to meet this requirement. To enable the -- repository parameter, the composer.json file needs to be in the following format: 75 | ```json 76 | { 77 | "extra": { 78 | "push": [{ 79 | "name": "prod", 80 | "type": "artifactory", 81 | "url": "https://jfrog-art.com/artifactory/composer-local/", 82 | "username": "admin", 83 | "password": "admin123", 84 | "ignore-by-git-attributes": true, 85 | "ignore": [ 86 | "test.php", 87 | "foo/" 88 | ] 89 | }, { 90 | "name": "dev", 91 | "url": "http://localhost:8081/repository/composer-devs", 92 | "username": "admin", 93 | "password": "admin123", 94 | "ignore-by-git-attributes": true, 95 | "ignore": [ 96 | "test.php", 97 | "foo/" 98 | ] 99 | }] 100 | } 101 | } 102 | ``` 103 | Above configuration may be called multi repository configuration format. 104 | 105 | The new version continues to support parsing the unique repository configuration format, but remember that you cannot use the -- repository command line argument in this scenario. 106 | 107 | The `username` and `password` can be specified in the `auth.json` file on a per-user basis with the [authentication mechanism provided by Composer](https://getcomposer.org/doc/articles/http-basic-authentication.md). 108 | 109 | ### Global configuration 110 | It's also possible to add some configuration inside global `composer.json` located at composer home (`composer config -g home`). 111 | 112 | Following precedence order will be used for each key: 113 | - command-line parameter 114 | - local `composer.json` 115 | - global `composer.json` 116 | - default 117 | 118 | Array values will not be merged. 119 | 120 | The command-line parameter -- repository is required if local configuration is multi repository. Global unique repository configuration will be ignored in that case. 121 | 122 | Multi repository configuration will be merged by the `name` key. 123 | 124 | ## Providers 125 | Specificity for some of the providers. 126 | 127 | ### Nexus 128 | 129 | ***Source type, URL, reference*** 130 | 131 | This is an optional part that can be added to the composer.json file provided for the package which can contain the source reference for this version. 132 | This option is useful in case you have a source manager and you would like to have a direct link to the source of an specific version. 133 | The example above given will read the last commit ID from git and the remote address from git as well which is quiet simple and useful. 134 | 135 | ### Artifactory 136 | 137 | **Tokens** 138 | 139 | Tokens are currently supported when used as password. The username is still required for it to work. 140 | 141 | Standalone tokens are currently not supported. 142 | 143 | Example of token usage: 144 | ```json 145 | { 146 | "extra": { 147 | "push": { 148 | "url": "https://jfrog-art.com/artifactory/composer-local", 149 | "type": "artifactory", 150 | "username": "", 151 | "password": "" 152 | } 153 | } 154 | } 155 | ``` 156 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elendev/composer-push", 3 | "description": "Provide a Push command to composer to push to repositories", 4 | "type": "composer-plugin", 5 | "license": "Apache-2.0", 6 | "authors": [ 7 | { 8 | "name": "Elendev", 9 | "email": "jonas.renaudot@gmail.com" 10 | } 11 | ], 12 | "autoload": { 13 | "psr-4": {"Elendev\\ComposerPush\\": "src/"} 14 | }, 15 | "require": { 16 | "php": ">=7.2 || ^8.0", 17 | "ext-curl": "*", 18 | "ext-json": "*", 19 | "ext-zip": "*", 20 | "composer-plugin-api": "^1.1|^2.0", 21 | "guzzlehttp/guzzle": "^6.0|^7.0", 22 | "symfony/finder": "^4.0|^5.0|^6.0", 23 | "symfony/filesystem": "^4.0|^5.0|^6.0" 24 | }, 25 | "extra": { 26 | "class": "Elendev\\ComposerPush\\Plugin" 27 | }, 28 | "require-dev": { 29 | "friendsofphp/php-cs-fixer": "^2.18|^3.4.0", 30 | "phpunit/phpunit": "^8 || ^9 || ^10", 31 | "composer/composer": "^1.8 || ^2.0" 32 | }, 33 | "scripts": { 34 | "test": "phpunit tests", 35 | "cs-fixer": "php-cs-fixer fix src" 36 | 37 | }, 38 | "replace": { 39 | "elendev/nexus-composer-push": "*" 40 | }, 41 | "suggest": { 42 | "elendev/composer-push": "Replaces the elendev/nexus-composer-push repository, which is deprecated." 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | tests 10 | tests/ZipArchiverTest/TypicalArchive 11 | tests/ZipArchiverTest/ComposerJsonArchive 12 | 13 | 14 | 15 | 16 | 17 | src 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /src/Configuration.php: -------------------------------------------------------------------------------- 1 | input = $input; 36 | $this->composer = $composer; 37 | $this->io = $io; 38 | } 39 | 40 | /** 41 | * Get the Nexus extra values if available 42 | * @param $parameter 43 | * @param null $default 44 | * @return mixed|null 45 | */ 46 | public function get($parameter, $default = null) 47 | { 48 | if ($this->config === null) { 49 | $this->config = $this->parseNexusExtra($this->input, $this->composer); 50 | } 51 | 52 | if (array_key_exists($parameter, $this->config) && $this->config[$parameter] !== null) { 53 | return $this->config[$parameter]; 54 | } else { 55 | return $default; 56 | } 57 | } 58 | 59 | /** 60 | * Return the package name based on the given name or the real package name. 61 | * 62 | * @param \Symfony\Component\Console\Input\InputInterface|null $input 63 | * 64 | * @return string 65 | */ 66 | public function getPackageName() 67 | { 68 | if ($this->input && $this->input->getOption('name')) { 69 | return $this->input->getOption('name'); 70 | } else { 71 | return $this->composer->getPackage()->getName(); 72 | } 73 | } 74 | 75 | /** 76 | * Return the repository URL, based on the configuration or the user input 77 | * @return string 78 | */ 79 | public function getUrl() 80 | { 81 | $url = $this->input->getOption('url'); 82 | 83 | if (empty($url)) { 84 | $url = $this->get('url'); 85 | 86 | if (empty($url)) { 87 | throw new \InvalidArgumentException('The option --url is required or has to be provided as an extra argument in composer.json'); 88 | } 89 | } 90 | 91 | return $url; 92 | } 93 | 94 | /** 95 | * Return the package version 96 | * If version argument is not set, will return composer.json version property 97 | * @return string 98 | */ 99 | public function getVersion() 100 | { 101 | $versionArgument = $this->input->getArgument('version'); 102 | return empty($versionArgument) ? $this->composer->getPackage()->getVersion() : $versionArgument; 103 | } 104 | 105 | /** 106 | * Return the source type 107 | * @return bool|string|string[]|null 108 | */ 109 | public function getSourceType() 110 | { 111 | return $this->input->getOption('src-type'); 112 | } 113 | 114 | /** 115 | * Return the source URL 116 | * @return bool|string|string[]|null 117 | */ 118 | public function getSourceUrl() 119 | { 120 | return $this->input->getOption('src-url'); 121 | } 122 | 123 | /** 124 | * Return the source reference 125 | * @return bool|string|string[]|null 126 | */ 127 | public function getSourceReference() 128 | { 129 | return $this->input->getOption('src-ref'); 130 | } 131 | 132 | /** 133 | * Return the username given in parameters during call 134 | * @return string|null 135 | */ 136 | public function getOptionUsername() 137 | { 138 | return $this->input->getOption('username'); 139 | } 140 | 141 | /** 142 | * Return the password given in parameters during call 143 | * @return string|null 144 | */ 145 | public function getOptionPassword() 146 | { 147 | return $this->input->getOption('password'); 148 | } 149 | 150 | /** 151 | * Type of repository. Default: nexus (lowercase) 152 | * @return string 153 | */ 154 | public function getType() 155 | { 156 | $type = $this->input->getOption('type'); 157 | 158 | if (empty($type)) { 159 | $type = $this->get('type', 'nexus'); 160 | } 161 | 162 | return $type; 163 | } 164 | 165 | /** 166 | * Return the access token 167 | * @return string 168 | */ 169 | public function getAccessToken() 170 | { 171 | return $this->input->getOption('access-token'); 172 | } 173 | 174 | /** 175 | * @return boolean 176 | */ 177 | public function getVerifySsl() 178 | { 179 | $verifySsl = $this->input->getOption('ssl-verify'); 180 | 181 | if ($verifySsl === null) { 182 | $verifySsl = $this->get('ssl-verify', true); 183 | } 184 | 185 | return filter_var($verifySsl, FILTER_VALIDATE_BOOLEAN); 186 | } 187 | 188 | /** 189 | * Fetch any directories or files to be excluded from zip creation 190 | * 191 | * @return array 192 | */ 193 | public function getIgnores() 194 | { 195 | // Remove after removal of --ignore-dirs option 196 | $deprecatedIgnores = $this->getDirectoriesToIgnore($this->input); 197 | 198 | $optionalIgnore = $this->input->getOption('ignore'); 199 | $composerIgnores = $this->get('ignore', []); 200 | $gitAttrIgnores = $this->getGitAttributesExportIgnores($this->input); 201 | $composerJsonIgnores = $this->getComposerJsonArchiveExcludeIgnores($this->input); 202 | 203 | if (! $this->input->getOption('keep-vendor')) { 204 | $defaultIgnores = ['vendor/']; 205 | } else { 206 | $defaultIgnores = []; 207 | } 208 | 209 | $ignore = array_merge($deprecatedIgnores, $composerIgnores, $optionalIgnore, $gitAttrIgnores, $composerJsonIgnores, $defaultIgnores); 210 | return array_unique($ignore); 211 | } 212 | 213 | /** 214 | * @param InputInterface $input 215 | * @deprecated argument has been changed to ignore 216 | * @return array 217 | */ 218 | private function getDirectoriesToIgnore(InputInterface $input) 219 | { 220 | $optionalIgnore = $input->getOption('ignore-dirs') ?? []; 221 | $composerIgnores = $this->get('ignore-dirs', []); 222 | 223 | if (!empty($optionalIgnore)) { 224 | $this->io->write('The --ignore-dirs option has been deprecated. Please use --ignore instead'); 225 | } 226 | 227 | if (!empty($composerIgnores)) { 228 | $this->io->write('The ignore-dirs config option has been deprecated. Please use ignore instead'); 229 | } 230 | 231 | $ignore = array_merge($composerIgnores, $optionalIgnore); 232 | return array_unique($ignore); 233 | } 234 | 235 | private function getGitAttributesExportIgnores(InputInterface $input) 236 | { 237 | $option = $input->getOption('ignore-by-git-attributes'); 238 | $extra = $this->get('ignore-by-git-attributes', false); 239 | if (!$option && !$extra) { 240 | return []; 241 | } 242 | 243 | $path = getcwd() . '/.gitattributes'; 244 | if (!is_file($path)) { 245 | return []; 246 | } 247 | 248 | $contents = file_get_contents($path); 249 | $lines = explode(PHP_EOL, $contents); 250 | $ignores = []; 251 | foreach ($lines as $line) { 252 | if ($line = trim($line)) { 253 | // ignore if end with `export-ignore` 254 | $diff = strlen($line) - 13; 255 | if ($diff > 0 && strpos($line, 'export-ignore', $diff) !== false) { 256 | $ignores[] = trim(trim(explode(' ', $line)[0]), DIRECTORY_SEPARATOR); 257 | } 258 | } 259 | } 260 | 261 | return $ignores; 262 | } 263 | 264 | private function getComposerJsonArchiveExcludeIgnores(InputInterface $input) 265 | { 266 | $option = $input->getOption('ignore-by-composer'); 267 | $extra = $this->get('ignore-by-composer', false); 268 | if (!$option && !$extra) { 269 | return []; 270 | } 271 | 272 | $ignores = []; 273 | foreach ($this->composer->getPackage()->getArchiveExcludes() as $exclude) { 274 | $ignores[] = trim($exclude, DIRECTORY_SEPARATOR); 275 | } 276 | 277 | return $ignores; 278 | } 279 | 280 | /** 281 | * @param InputInterface $input 282 | * @param Composer $composer 283 | * 284 | * @return array 285 | * @throws \InvalidArgumentException|InvalidConfigException 286 | */ 287 | private function parseNexusExtra(InputInterface $input, Composer $composer) 288 | { 289 | $globalComposer = $composer->getPluginManager()->getGlobalComposer(); 290 | $globalExtras = !empty($globalComposer) ? $globalComposer->getPackage()->getExtra() : null; 291 | $localExtras = $composer->getPackage()->getExtra(); 292 | 293 | $localExtrasConfigurationKey = 'push'; 294 | if (empty($localExtras['push'])) { 295 | if (!empty($localExtras['nexus-push'])) { 296 | $localExtrasConfigurationKey = 'nexus-push'; 297 | $this->io->warning('Configuration under extra - nexus-push in composer.json is deprecated, please replace it by extra - push'); 298 | } 299 | } 300 | 301 | $globalConfig = !empty($globalExtras['push']) ? $globalExtras['push'] : null; 302 | $localConfig = !empty($localExtras[$localExtrasConfigurationKey]) ? $localExtras[$localExtrasConfigurationKey] : null; 303 | 304 | $repository = $input->getOption(PushCommand::REPOSITORY); 305 | if (empty($repository) && !empty($localConfig[0])) { 306 | throw new \InvalidArgumentException('As configurations in composer.json support upload to multi repository, the option --repository is required'); 307 | } 308 | if (!empty($repository) && empty($globalConfig[0]) && empty($localConfig[0])) { 309 | throw new InvalidConfigException('the option --repository is offered, but configurations in composer.json doesn\'t support upload to multi repository, please check'); 310 | } 311 | 312 | if (!empty($repository)) { 313 | $globalRepository = $this->getRepositoryConfig($globalConfig, $repository); 314 | $localRepository = $this->getRepositoryConfig($localConfig, $repository); 315 | 316 | if (empty($globalRepository) && empty($localRepository)) { 317 | throw new \InvalidArgumentException('The value of option --repository match no push configuration, please check'); 318 | } 319 | 320 | return array_replace($globalRepository ?? [], $localRepository ?? []); 321 | } 322 | 323 | return array_replace($globalConfig ?? [], $localConfig ?? []); 324 | } 325 | 326 | /** 327 | * @param mixed $extras 328 | * @param string $name 329 | * 330 | * @return mixed|null 331 | * @throws InvalidConfigException 332 | */ 333 | private function getRepositoryConfig($extras, $name) 334 | { 335 | if (empty($extras[0])) { 336 | return null; 337 | } 338 | 339 | foreach ($extras as $key => $repository) { 340 | if (empty($repository[self::PUSH_CFG_NAME])) { 341 | $fmt = 'The push configuration array in composer.json with index {%s} needs to provide the value for key "%s"'; 342 | $exceptionMsg = sprintf($fmt, $key, self::PUSH_CFG_NAME); 343 | throw new InvalidConfigException($exceptionMsg); 344 | } 345 | if ($repository[self::PUSH_CFG_NAME] === $name) { 346 | return $repository; 347 | } 348 | } 349 | 350 | return null; 351 | } 352 | } 353 | -------------------------------------------------------------------------------- /src/InvalidConfigException.php: -------------------------------------------------------------------------------- 1 | 'Elendev\ComposerPush\PushCommandProvider', 20 | ); 21 | } 22 | 23 | public function deactivate(Composer $composer, IOInterface $io) 24 | { 25 | } 26 | 27 | public function uninstall(Composer $composer, IOInterface $io) 28 | { 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/PushCommand.php: -------------------------------------------------------------------------------- 1 | register(false); 16 | } 17 | 18 | use Composer\Command\BaseCommand; 19 | use Composer\IO\IOInterface; 20 | use Elendev\ComposerPush\RepositoryProvider\AbstractProvider; 21 | use Symfony\Component\Console\Exception\InvalidArgumentException; 22 | use Symfony\Component\Console\Input\InputArgument; 23 | use Symfony\Component\Console\Input\InputInterface; 24 | use Symfony\Component\Console\Input\InputOption; 25 | use Symfony\Component\Console\Output\OutputInterface; 26 | 27 | class PushCommand extends BaseCommand 28 | { 29 | /** 30 | * @var Configuration 31 | */ 32 | private $configuration; 33 | 34 | public const REPOSITORY = 'repository'; 35 | 36 | public const PROVIDER_TYPES = [ 37 | 'nexus' => 'Elendev\ComposerPush\RepositoryProvider\NexusProvider', 38 | 'artifactory' => 'Elendev\ComposerPush\RepositoryProvider\ArtifactoryProvider' 39 | ]; 40 | 41 | protected function configure() 42 | { 43 | $this 44 | ->setName('push') 45 | ->setAliases(['nexus-push']) // Deprecated, use push instead 46 | ->setDescription('Initiate a push to a distant repository') 47 | ->setDefinition([ 48 | new InputArgument('version', InputArgument::OPTIONAL, 'The package version, if not set take composer.json version'), 49 | new InputOption('name', null, InputArgument::OPTIONAL, 'Name of the package (if different from the composer.json file)'), 50 | new InputOption('url', null, InputArgument::OPTIONAL, 'URL to the distant repository'), 51 | new InputOption('type', null, InputArgument::OPTIONAL, 'Type of the distant repository (default: nexus, available: [' . implode(', ', array_keys(self::PROVIDER_TYPES)) . '])'), 52 | new InputOption(self::REPOSITORY, null, InputArgument::OPTIONAL, 'which repository to save, use this parameter if you want to place development version and production version in different repository'), 53 | new InputOption( 54 | 'username', 55 | null, 56 | InputArgument::OPTIONAL, 57 | 'Username to log in the distant Nexus repository' 58 | ), 59 | new InputOption('password', null, InputArgument::OPTIONAL, 'Password to log in the distant Nexus repository'), 60 | new InputOption('ignore', 'i', InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Directories and files to ignore when creating the zip'), 61 | new InputOption('ignore-dirs', null, InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'DEPRECATED Directories to ignore when creating the zip'), 62 | new InputOption('ignore-by-git-attributes', null, InputOption::VALUE_NONE, 'Ignore .gitattrbutes export-ignore directories when creating the zip'), 63 | new InputOption('ignore-by-composer', null, InputOption::VALUE_NONE, 'Ignore composer.json archive-exclude files and directories when creating the zip'), 64 | new InputOption('src-type', null, InputArgument::OPTIONAL, 'The source type (git/svn,...) pushed on composer on distant Nexus repository'), 65 | new InputOption('src-url', null, InputArgument::OPTIONAL, 'The source url pushed on composer on distant Nexus repository'), 66 | new InputOption('src-ref', null, InputArgument::OPTIONAL, 'The source reference pushed on composer on distant Nexus repository'), 67 | new InputOption('keep-vendor', null, InputOption::VALUE_NONE, 'Keep vendor directory when creating zip'), 68 | new InputOption('keep-dot-files', null, InputOption::VALUE_NONE, 'Keep dots files/dirs when creating zip'), 69 | new InputOption('ssl-verify', null, InputOption::VALUE_OPTIONAL, 'Enable (true) or disable (false) the SSL verification'), 70 | new InputOption('access-token', null, InputOption::VALUE_OPTIONAL, 'Access Token to get authenticated with'), 71 | ]) 72 | ->setHelp( 73 | <<nexus-push command uses the archive command to create a ZIP 75 | archive and send it to the configured (or given) nexus repository. 76 | EOT 77 | ) 78 | ; 79 | } 80 | 81 | /** 82 | * @param \Symfony\Component\Console\Input\InputInterface $input 83 | * @param \Symfony\Component\Console\Output\OutputInterface $output 84 | * 85 | * @return int|null|void 86 | * @throws \Exception 87 | * @throws \GuzzleHttp\Exception\GuzzleException 88 | */ 89 | protected function execute(InputInterface $input, OutputInterface $output) 90 | { 91 | $sourceType = $input->getOption('src-type'); 92 | $sourceUrl = $input->getOption('src-url'); 93 | $sourceReference = $input->getOption('src-ref'); 94 | // we will check to see if any of these are available, and if so, and not all of them we will inform the user 95 | if (!empty($sourceType) || !empty($sourceUrl) || !empty($sourceReference)) { 96 | if (empty($sourceType) || empty($sourceUrl) || empty($sourceReference)) { 97 | throw new InvalidArgumentException('Source reference parameters are not complete, you should set all three parameters (type, url, ref) or none of them, please check'); 98 | } 99 | } 100 | 101 | $fileName = tempnam(sys_get_temp_dir(), 'composer-push') . '.zip'; 102 | 103 | $composer = $this->getComposer(true); 104 | 105 | $this->configuration = new Configuration($input, $composer, $this->getIO()); 106 | 107 | if (empty($this->configuration->getVersion())) { 108 | throw new InvalidArgumentException('No version found, you chould either provide version argument in the command or add version in composer.json'); 109 | } 110 | 111 | $packageName = $this->configuration->getPackageName(); 112 | 113 | $subdirectory = strtolower(preg_replace( 114 | '/[^a-zA-Z0-9_]|\./', 115 | '-', 116 | $packageName . '-' . $this->configuration->getVersion() 117 | )); 118 | 119 | $ignoredDirectories = $this->configuration->getIgnores(); 120 | 121 | $this->getIO() 122 | ->write( 123 | 'Ignore directories: ' . join(' ', $ignoredDirectories), 124 | true, 125 | IOInterface::VERY_VERBOSE 126 | ); 127 | 128 | try { 129 | ZipArchiver::archiveDirectory( 130 | getcwd(), 131 | $fileName, 132 | $this->configuration->getVersion(), 133 | $subdirectory, 134 | $ignoredDirectories, 135 | $input->getOption('keep-dot-files'), 136 | $this->getIO() 137 | ); 138 | 139 | $provider = $this->getProvider(); 140 | 141 | $this->getIO() 142 | ->write( 143 | 'Pushing archive to URL: ' . $provider->getUrl() . '...', 144 | true 145 | ); 146 | 147 | $provider->sendFile($fileName); 148 | 149 | $this->getIO() 150 | ->write('Archive correctly pushed to the ' . ucfirst($this->configuration->getType()) . ' server'); 151 | 152 | return 0; 153 | } finally { 154 | $this->getIO() 155 | ->write( 156 | 'Remove file ' . $fileName, 157 | true, 158 | IOInterface::VERY_VERBOSE 159 | ); 160 | unlink($fileName); 161 | } 162 | } 163 | 164 | /** 165 | * Return a provider given the type 166 | * @param $type 167 | * @return AbstractProvider 168 | */ 169 | private function getProvider($type = null) 170 | { 171 | if (empty($type) && empty($type = $this->configuration->getType())) { 172 | $type = 'nexus'; 173 | } 174 | 175 | if (!array_key_exists($type, self::PROVIDER_TYPES)) { 176 | throw new \InvalidArgumentException("Provider of type $type does not exist"); 177 | } 178 | 179 | $class = self::PROVIDER_TYPES[$type]; 180 | 181 | if (!class_exists($class)) { 182 | throw new \RuntimeException("Provider of type $type: class $class not found"); 183 | } 184 | 185 | return new $class($this->configuration, $this->getIO()); 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/PushCommandProvider.php: -------------------------------------------------------------------------------- 1 | configuration = $configuration; 36 | $this->io = $io; 37 | $this->client = $client; 38 | if (method_exists($io, 'getProgressBar')) { 39 | $this->progress = $io->getProgressBar(); 40 | } 41 | } 42 | 43 | /** 44 | * Get the URL used for the provider 45 | * @return mixed 46 | */ 47 | abstract public function getUrl(); 48 | 49 | /** 50 | * Try to send a file with the given username/password. If the credentials 51 | * are not set, try to send a simple request without credentials. If the 52 | * send fail with a 401, try to use the credentials that may be available 53 | * in an `auth.json` file or in the 54 | * `extra` section 55 | * 56 | * @param string $filePath path to the file to send 57 | * @throws \Exception 58 | */ 59 | public function sendFile( 60 | $filePath 61 | ) { 62 | $username = $this->getConfiguration()->getOptionUsername(); 63 | $password = $this->getConfiguration()->getOptionPassword(); 64 | 65 | if (!empty($username) && !empty($password)) { 66 | $this->postFile($filePath, $username, $password); 67 | return; 68 | } else { 69 | $credentials = []; 70 | 71 | if ($this->getConfiguration()->get('username') !== null && $this->getConfiguration()->get('password')) { 72 | $credentials['extra'] = [ 73 | 'username' => $this->getConfiguration()->get('username'), 74 | 'password' => $this->getConfiguration()->get('password'), 75 | ]; 76 | } 77 | 78 | if ($this->getConfiguration()->getAccessToken()) { 79 | $credentials['access_token']['token'] = $this->getConfiguration()->getAccessToken(); 80 | } 81 | 82 | if (preg_match( 83 | '{^(?:https?)://([^/]+)(?:/.*)?}', 84 | $this->getUrl(), 85 | $match 86 | ) && $this->getIO()->hasAuthentication($match[1])) { 87 | $auth = $this->getIO()->getAuthentication($match[1]); 88 | $credentials['auth.json'] = [ 89 | 'username' => $auth['username'], 90 | 'password' => $auth['password'], 91 | ]; 92 | } 93 | 94 | // In the case anything else works, try to connect without any credentials. 95 | $credentials['none'] = []; 96 | 97 | foreach ($credentials as $type => $credential) { 98 | $this->getIO() 99 | ->write( 100 | '[postFile] Trying credentials ' . $type, 101 | true, 102 | IOInterface::VERY_VERBOSE 103 | ); 104 | 105 | try { 106 | if (!empty($credential['token'])) { 107 | $this->getIO() 108 | ->write( 109 | '[postFile] Use ' . $type, 110 | true, 111 | IOInterface::VERY_VERBOSE 112 | ); 113 | $this->postFileWithToken($filePath, $credential['token']); 114 | } elseif (!empty($credential['username']) && !empty($credential['password'])) { 115 | $this->getIO() 116 | ->write( 117 | '[postFile] Use user ' . $credential['username'], 118 | true, 119 | IOInterface::VERY_VERBOSE 120 | ); 121 | $this->postFile( 122 | $filePath, 123 | $credential['username'], 124 | $credential['password'] 125 | ); 126 | } else { 127 | $this->getIO() 128 | ->write( 129 | '[postFile] Use no credentials', 130 | true, 131 | IOInterface::VERY_VERBOSE 132 | ); 133 | $this->postFile($filePath); 134 | } 135 | 136 | return; 137 | } catch (ClientException $e) { 138 | if ($e->getResponse()->getStatusCode() === '401') { 139 | if ($type === 'none') { 140 | $this->getIO() 141 | ->write( 142 | 'Unable to push on server (authentication required)', 143 | true, 144 | IOInterface::VERY_VERBOSE 145 | ); 146 | } else { 147 | $this->getIO() 148 | ->write( 149 | 'Unable to authenticate on server with credentials ' . $type, 150 | true, 151 | IOInterface::VERY_VERBOSE 152 | ); 153 | } 154 | } else { 155 | $this->getIO() 156 | ->writeError( 157 | 'A network error occured while trying to upload to the server: ' . $e->getMessage(), 158 | true, 159 | IOInterface::QUIET 160 | ); 161 | } 162 | } 163 | } 164 | } 165 | 166 | throw new \Exception('Impossible to push to remote repository, use -vvv to have more details'); 167 | } 168 | 169 | /** 170 | * Process the API call 171 | * @param $file file to upload 172 | * @param $options http call options 173 | */ 174 | abstract protected function apiCall($file, $options); 175 | 176 | /** 177 | * The file has to be uploaded by hand because of composer limitations 178 | * (impossible to use Guzzle functions.php file in a composer plugin). 179 | * 180 | * @param $file 181 | * @param $username 182 | * @param $password 183 | * 184 | * @throws \GuzzleHttp\Exception\GuzzleException 185 | */ 186 | protected function postFile($file, $username = null, $password = null) 187 | { 188 | $options = []; 189 | 190 | if (!empty($username) && !empty($password)) { 191 | $options['auth'] = [$username, $password]; 192 | } 193 | 194 | $this->apiCall($file, $options); 195 | } 196 | 197 | /** 198 | * Post the given file with access token 199 | * @param $file 200 | * @param string $token 201 | * @return mixed 202 | */ 203 | protected function postFileWithToken($file, $token) 204 | { 205 | $options = []; 206 | $options['headers']['Authorization'] = 'Bearer ' . $token; 207 | $this->apiCall($file, $options); 208 | } 209 | 210 | /** 211 | * @return Configuration 212 | */ 213 | protected function getConfiguration() 214 | { 215 | return $this->configuration; 216 | } 217 | 218 | /** 219 | * @return IOInterface 220 | */ 221 | protected function getIO() 222 | { 223 | return $this->io; 224 | } 225 | 226 | /** 227 | * @throws FileNotFoundException 228 | * @return \GuzzleHttp\Client|\GuzzleHttp\ClientInterface 229 | */ 230 | protected function getClient() 231 | { 232 | if (empty($this->client)) { 233 | $this->client = new Client([ 234 | 'verify' => $this->configuration->getVerifySsl() 235 | ]); 236 | } 237 | return $this->client; 238 | } 239 | 240 | /** 241 | * Return the Progress Callback for Guzzle 242 | * 243 | * @return callback 244 | */ 245 | protected function getProgressCallback() 246 | { 247 | if ($this->progress === null) { 248 | return function ( 249 | $downloadTotal, 250 | $downloadedBytes, 251 | $uploadTotal, 252 | $uploadedBytes 253 | ) { 254 | //Do nothing 255 | }; 256 | } 257 | return function ( 258 | $downloadTotal, 259 | $downloadedBytes, 260 | $uploadTotal, 261 | $uploadedBytes 262 | ) { 263 | if ($uploadTotal === 0) { 264 | return; 265 | } 266 | if ($uploadedBytes === 0) { 267 | $this->progress->start(100); 268 | return; 269 | } 270 | 271 | if ($uploadedBytes === $uploadTotal) { 272 | if ($this->progress->getProgress() != 100) { 273 | $this->progress->setProgress(100); 274 | $this->progress->finish(); 275 | $this->getIO()->write(''); 276 | } 277 | return; 278 | } 279 | 280 | $this->progress->setProgress((int) (($uploadedBytes / $uploadTotal) * 100)); 281 | }; 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /src/RepositoryProvider/ArtifactoryProvider.php: -------------------------------------------------------------------------------- 1 | getConfiguration()->getUrl(); 13 | $name = $this->getConfiguration()->getPackageName(); 14 | $version = $this->getConfiguration()->getVersion(); 15 | 16 | $nameArray = explode('/', $name); 17 | $moduleName = end($nameArray); 18 | 19 | if (empty($url)) { 20 | throw new \InvalidArgumentException('The option --url is required or has to be provided as an extra argument in composer.json'); 21 | } 22 | 23 | if (empty($version)) { 24 | throw new \InvalidArgumentException('The version argument is required'); 25 | } 26 | 27 | // Remove trailing slash from URL 28 | $url = preg_replace('{/$}', '', $url); 29 | 30 | return sprintf('%s/%s/%s-%s', $url, $name, $moduleName, $version); 31 | } 32 | 33 | /** 34 | * Process the API call 35 | * @param $file file to upload 36 | * @param $options http call options 37 | */ 38 | protected function apiCall($file, $options) 39 | { 40 | $options['debug'] = $this->getIO()->isVeryVerbose(); 41 | $options['body'] = fopen($file, 'r'); 42 | $options['progress'] = $this->getProgressCallback(); 43 | $url = $this->getUrl() . '.' . pathinfo($file, PATHINFO_EXTENSION) . '?properties=composer.version=' . $this->getConfiguration()->getVersion(); 44 | $this->getClient()->request('PUT', $url, $options); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/RepositoryProvider/NexusProvider.php: -------------------------------------------------------------------------------- 1 | getConfiguration()->getUrl(); 13 | $name = $this->getConfiguration()->getPackageName(); 14 | $version = $this->getConfiguration()->getVersion(); 15 | 16 | if (empty($url)) { 17 | throw new \InvalidArgumentException('The option --url is required or has to be provided as an extra argument in composer.json'); 18 | } 19 | 20 | if (empty($version)) { 21 | throw new \InvalidArgumentException('The version argument is required'); 22 | } 23 | 24 | // Remove trailing slash from URL 25 | $url = preg_replace('{/$}', '', $url); 26 | 27 | return sprintf('%s/packages/upload/%s/%s', $url, $name, $version); 28 | } 29 | 30 | /** 31 | * Process the API call 32 | * @param $file file to upload 33 | * @param $options http call options 34 | */ 35 | protected function apiCall($file, $options) 36 | { 37 | $url = $this->getUrl(); 38 | 39 | $sourceType = $this->getConfiguration()->getSourceType(); 40 | $sourceUrl = $this->getConfiguration()->getSourceUrl(); 41 | $sourceReference = $this->getConfiguration()->getSourceReference(); 42 | 43 | $options['debug'] = $this->getIO()->isVeryVerbose(); 44 | 45 | if (!empty($sourceType) && !empty($sourceUrl) && !empty($sourceReference)) { 46 | $options['multipart'] = [ 47 | [ 48 | 'Content-Type' => 'application/zip', 49 | 'name' => 'package', 50 | 'contents' => fopen($file, 'r') 51 | ], 52 | [ 53 | 'name' => 'src-type', 54 | 'contents' => $sourceType 55 | ], 56 | [ 57 | 'name' => 'src-url', 58 | 'contents' => $sourceUrl 59 | ], 60 | [ 61 | 'name' => 'src-ref', 62 | 'contents' => $sourceReference 63 | ] 64 | ]; 65 | } else { 66 | $options['body'] = fopen($file, 'r'); 67 | } 68 | 69 | $options['progress'] = $this->getProgressCallback(); 70 | 71 | $this->getClient()->request('PUT', $url, $options); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/ZipArchiver.php: -------------------------------------------------------------------------------- 1 | write('[ZIP Archive] Archive into the subdirectory ' . $subDirectory); 39 | } else { 40 | $io->write('[ZIP Archive] Archive into root directory'); 41 | } 42 | 43 | $finder = new Finder(); 44 | $fileSystem = new Filesystem(); 45 | 46 | $finder->in($source)->ignoreVCS(true)->ignoreDotFiles(!$keepDotFiles); 47 | 48 | foreach ($ignores as $ignore) { 49 | $finder->notPath($ignore); 50 | } 51 | 52 | $archive = new \ZipArchive(); 53 | 54 | $io->write( 55 | 'Create ZIP file ' . $destination, 56 | true, 57 | IOInterface::VERY_VERBOSE 58 | ); 59 | 60 | if ($archive->open($destination, \ZipArchive::CREATE) !== true) { 61 | $io->writeError( 62 | 'Impossible to create ZIP file ' . $destination, 63 | true 64 | ); 65 | throw new \Exception('Impossible to create the file ' . $destination); 66 | } 67 | 68 | foreach ($finder as $fileInfo) { 69 | if ($subDirectory) { 70 | $zipPath = $subDirectory . '/'; 71 | } else { 72 | $zipPath = ''; 73 | } 74 | 75 | $zipPath .= rtrim($fileSystem->makePathRelative( 76 | $fileInfo->getRealPath(), 77 | $source 78 | ), '/'); 79 | 80 | if (!$fileInfo->isFile()) { 81 | continue; 82 | } 83 | 84 | $io->write( 85 | 'Zip file ' . $fileInfo->getPath() . ' to ' . $zipPath, 86 | true, 87 | IOInterface::VERY_VERBOSE 88 | ); 89 | 90 | $archive->addFile($fileInfo->getRealPath(), $zipPath); 91 | } 92 | 93 | $archive->close(); 94 | 95 | $io->write('Update version in ZIP archive to ' . $version, true, IOInterface::VERBOSE); 96 | 97 | self::updateVersion($destination, $subDirectory, $version, $io); 98 | 99 | $io->write('Zip archive ' . $destination . ' done'); 100 | } 101 | 102 | /** 103 | * Update the version of the composer.json file of the zip archive 104 | * @param $zipFile 105 | * @param $subDirectory 106 | * @param $version 107 | * @param \Composer\IO\IOInterface|null $io 108 | * @throws \Exception 109 | */ 110 | private static function updateVersion($zipFile, $subDirectory, $version, $io) 111 | { 112 | $archive = new \ZipArchive(); 113 | 114 | if ($archive->open($zipFile) !== true) { 115 | throw new \Exception('Impossible to update Composer version in composer.json'); 116 | } 117 | 118 | $filePath = ($subDirectory ? $subDirectory . '/' : '') . 'composer.json'; 119 | 120 | $content = json_decode($archive->getFromName($filePath)); 121 | 122 | if ($content === false || $content === null) { 123 | $io->write('No composer.json file in the archive (path: ' . $filePath . ')', true, IOInterface::VERBOSE); 124 | return; 125 | } 126 | 127 | $content->version = $version; 128 | 129 | $archive->deleteName($filePath); 130 | 131 | $archive->addFromString($filePath, json_encode($content, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); 132 | 133 | $archive->close(); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /tests/ConfigurationTest.php: -------------------------------------------------------------------------------- 1 | keepVendor = null; 58 | $this->configIgnore = []; 59 | $this->composerPackageArchiveExcludes = []; 60 | $this->configIgnoreByComposer = null; 61 | $this->configOptionUrl = "https://option-url.com"; 62 | 63 | $this->localConfig = self::ComposerConfigSingle; 64 | $this->globalConfig = self::ComposerConfigEmpty; 65 | $this->configName = null; 66 | 67 | $this->configType = null; 68 | $this->extraConfigType = null; 69 | 70 | $this->configVerifySsl = null; 71 | $this->extraVerifySsl = null; 72 | 73 | $this->initGlobalConfiguration(); 74 | } 75 | 76 | 77 | public function testGetVersion() 78 | { 79 | $this->inputMock->method('getArgument')->willReturnCallback(function ($argument) { 80 | if ($argument === 'version') { 81 | return '1.0.1'; 82 | } 83 | }); 84 | $this->assertEquals('1.0.1', $this->configuration->getVersion()); 85 | } 86 | 87 | public function testGetVersionComposeJson() 88 | { 89 | $this->inputMock->method('getArgument')->willReturnCallback(function ($argument) { 90 | if ($argument === 'version') { 91 | return null; 92 | } 93 | }); 94 | $this->assertEquals('1.2.3', $this->configuration->getVersion()); 95 | } 96 | 97 | public function testGetSourceUrl() 98 | { 99 | $this->assertEquals('my-src-url', $this->configuration->getSourceUrl()); 100 | } 101 | 102 | public function testGetSourceType() 103 | { 104 | $this->assertEquals('my-src-type', $this->configuration->getSourceType()); 105 | } 106 | 107 | public function testGetAccessToken() 108 | { 109 | $this->assertEquals('my-token', $this->configuration->getAccessToken()); 110 | } 111 | 112 | public function testGetUrl() 113 | { 114 | $this->assertEquals('https://option-url.com', $this->configuration->getUrl()); 115 | 116 | $this->configOptionUrl = null; 117 | $this->initGlobalConfiguration(); 118 | 119 | $this->assertEquals('https://example.com', $this->configuration->getUrl()); 120 | } 121 | 122 | public function testGetVerifySsl() 123 | { 124 | $this->assertTrue($this->configuration->getVerifySsl()); 125 | 126 | $this->extraVerifySsl = false; 127 | $this->initGlobalConfiguration(); 128 | 129 | $this->assertFalse($this->configuration->getVerifySsl()); 130 | 131 | $this->extraVerifySsl = null; 132 | $this->configVerifySsl = false; 133 | $this->initGlobalConfiguration(); 134 | 135 | $this->assertFalse($this->configuration->getVerifySsl()); 136 | 137 | $this->extraVerifySsl = true; 138 | $this->configVerifySsl = false; 139 | $this->initGlobalConfiguration(); 140 | 141 | $this->assertFalse($this->configuration->getVerifySsl()); 142 | 143 | $this->extraVerifySsl = 'true'; 144 | $this->configVerifySsl = 'false'; 145 | $this->initGlobalConfiguration(); 146 | 147 | $this->assertFalse($this->configuration->getVerifySsl()); 148 | } 149 | 150 | public function testGet() 151 | { 152 | $this->assertEquals('https://example.com', $this->configuration->get('url')); 153 | $this->assertEquals('push-username', $this->configuration->get('username')); 154 | $this->assertEquals('push-password', $this->configuration->get('password')); 155 | 156 | $this->localConfig = self::ComposerConfigMulti; 157 | $this->repository = 'A'; 158 | 159 | $this->initGlobalConfiguration(); 160 | $this->assertEquals('https://a.com', $this->configuration->get('url')); 161 | $this->assertEquals('push-username-a', $this->configuration->get('username')); 162 | $this->assertEquals('push-password-a', $this->configuration->get('password')); 163 | 164 | $this->repository = 'B'; 165 | $this->initGlobalConfiguration(); 166 | $this->assertEquals('https://b.com', $this->configuration->get('url')); 167 | $this->assertEquals('push-username-b', $this->configuration->get('username')); 168 | $this->assertEquals('push-password-b', $this->configuration->get('password')); 169 | 170 | $this->repository = null; 171 | $this->initGlobalConfiguration(); 172 | 173 | $this->expectException(\InvalidArgumentException::class); 174 | $this->configuration->get('url'); 175 | } 176 | 177 | public function testGetIgnores() 178 | { 179 | $this->assertArrayEquals([ 180 | "option-dir1", 181 | "option-dir2", 182 | "vendor/" 183 | ], $this->configuration->getIgnores()); 184 | 185 | $this->keepVendor = true; 186 | $this->initGlobalConfiguration(); 187 | 188 | $this->assertArrayEquals([ 189 | "option-dir1", 190 | "option-dir2" 191 | ], $this->configuration->getIgnores()); 192 | 193 | $this->keepVendor = false; 194 | $this->configIgnore = ["config-dir1", "config-dir2", "config-dir3"]; 195 | 196 | $this->initGlobalConfiguration(); 197 | 198 | $this->assertArrayEquals([ 199 | "option-dir1", 200 | "option-dir2", 201 | "vendor/", 202 | "config-dir1", 203 | "config-dir2", 204 | "config-dir3", 205 | ], $this->configuration->getIgnores()); 206 | 207 | $this->composerPackageArchiveExcludes = ["my-package1", "my-package2"]; 208 | $this->initGlobalConfiguration(); 209 | 210 | $this->assertArrayEquals([ 211 | "option-dir1", 212 | "option-dir2", 213 | "vendor/", 214 | "config-dir1", 215 | "config-dir2", 216 | "config-dir3", 217 | ], $this->configuration->getIgnores()); 218 | 219 | $this->configIgnoreByComposer = true; 220 | $this->initGlobalConfiguration(); 221 | 222 | $this->assertArrayEquals([ 223 | "option-dir1", 224 | "option-dir2", 225 | "vendor/", 226 | "config-dir1", 227 | "config-dir2", 228 | "config-dir3", 229 | "my-package1", 230 | "my-package2" 231 | ], $this->configuration->getIgnores()); 232 | } 233 | 234 | public function testGetSourceReference() 235 | { 236 | $this->assertEquals('my-src-ref', $this->configuration->getSourceReference()); 237 | } 238 | 239 | public function testGetOptionPassword() 240 | { 241 | $this->assertEquals("my-password", $this->configuration->getOptionPassword()); 242 | } 243 | 244 | public function testGetType() 245 | { 246 | $this->assertEquals("nexus", $this->configuration->getType()); 247 | 248 | $this->extraConfigType = 'jfrog'; 249 | $this->initGlobalConfiguration(); 250 | $this->assertEquals("jfrog", $this->configuration->getType()); 251 | 252 | $this->configType = 'other-type'; 253 | $this->initGlobalConfiguration(); 254 | $this->assertEquals("other-type", $this->configuration->getType()); 255 | } 256 | 257 | public function testGetPackageName() 258 | { 259 | $this->assertEquals('composer-push-name', $this->configuration->getPackageName()); 260 | } 261 | 262 | public function testGetOptionUsername() 263 | { 264 | $this->assertEquals("my-username", $this->configuration->getOptionUsername()); 265 | } 266 | 267 | public function testGetGlobalConfig() 268 | { 269 | $this->configIgnore = ['dir1', 'dir2']; 270 | 271 | $this->splitConfig = true; 272 | $this->localConfig = self::ComposerConfigSingle; 273 | $this->globalConfig = self::ComposerConfigSingle; 274 | $this->repository = null; 275 | 276 | $this->initGlobalConfiguration(); 277 | $this->assertEquals('https://global.example.com', $this->configuration->get('url')); 278 | $this->assertArrayEquals($this->configIgnore, $this->configuration->get('ignore')); 279 | 280 | $this->splitConfig = false; 281 | $this->localConfig = self::ComposerConfigSingle; 282 | $this->globalConfig = self::ComposerConfigMulti; 283 | $this->repository = null; 284 | 285 | $this->initGlobalConfiguration(); 286 | $this->assertEquals('https://example.com', $this->configuration->get('url')); 287 | 288 | $this->repository = 'A'; 289 | 290 | $this->initGlobalConfiguration(); 291 | $this->assertEquals('https://global.a.com', $this->configuration->get('url')); 292 | 293 | $this->repository = 'B'; 294 | 295 | $this->initGlobalConfiguration(); 296 | $this->assertEquals('https://global.b.com', $this->configuration->get('url')); 297 | 298 | $this->localConfig = self::ComposerConfigMulti; 299 | $this->globalConfig = self::ComposerConfigSingle; 300 | $this->repository = null; 301 | 302 | $this->initGlobalConfiguration(); 303 | $this->expectException(\InvalidArgumentException::class); 304 | $this->configuration->get('url'); 305 | 306 | $this->localConfig = self::ComposerConfigMulti; 307 | $this->globalConfig = self::ComposerConfigMulti; 308 | $this->repository = 'A'; 309 | 310 | $this->initGlobalConfiguration(); 311 | $this->assertEquals('https://a.com', $this->configuration->get('url')); 312 | $this->assertEquals('global-push-username-a', $this->configuration->get('username')); 313 | 314 | $this->repository = 'B'; 315 | 316 | $this->initGlobalConfiguration(); 317 | $this->assertEquals('https://b.com', $this->configuration->get('url')); 318 | $this->assertEquals('global-push-username-b', $this->configuration->get('username')); 319 | 320 | 321 | $this->splitConfig = false; 322 | 323 | $this->localConfig = self::ComposerConfigEmpty; 324 | $this->globalConfig = self::ComposerConfigSingle; 325 | $this->repository = null; 326 | 327 | $this->initGlobalConfiguration(); 328 | $this->assertEquals('https://global.example.com', $this->configuration->get('url')); 329 | $this->assertEquals(null, $this->configuration->get('ignore')); 330 | 331 | $this->localConfig = self::ComposerConfigEmpty; 332 | $this->globalConfig = self::ComposerConfigMulti; 333 | $this->repository = 'A'; 334 | 335 | $this->initGlobalConfiguration(); 336 | $this->assertEquals('https://global.a.com', $this->configuration->get('url')); 337 | $this->assertEquals('global-push-username-a', $this->configuration->get('username')); 338 | 339 | $this->repository = 'B'; 340 | 341 | $this->initGlobalConfiguration(); 342 | $this->assertEquals('https://global.b.com', $this->configuration->get('url')); 343 | $this->assertEquals('global-push-username-b', $this->configuration->get('username')); 344 | } 345 | 346 | private function createInputMock() 347 | { 348 | $input = $this->createMock(InputInterface::class); 349 | $input->method('getOption')->willReturnCallback(function ($argument) { 350 | switch ($argument) { 351 | case 'name': 352 | return "composer-push-name"; 353 | case 'url': 354 | return $this->configOptionUrl; 355 | case 'src-type': 356 | return "my-src-type"; 357 | case 'src-url': 358 | return "my-src-url"; 359 | case 'src-ref': 360 | return "my-src-ref"; 361 | case 'username': 362 | return "my-username"; 363 | case 'password': 364 | return "my-password"; 365 | case 'ignore': 366 | return ["option-dir1", "option-dir2"]; 367 | case 'keep-vendor': 368 | return $this->keepVendor; 369 | case 'ignore-by-composer': 370 | return $this->configIgnoreByComposer; 371 | case 'repository': 372 | return $this->repository; 373 | case 'type': 374 | return $this->configType; 375 | case 'ssl-verify': 376 | return $this->configVerifySsl; 377 | case 'access-token': 378 | return 'my-token'; 379 | } 380 | }); 381 | 382 | return $input; 383 | } 384 | 385 | private function initGlobalConfiguration() 386 | { 387 | $this->inputMock = $this->createInputMock(); 388 | $this->composerMock = $this->createComposerMock(); 389 | $this->configuration = new Configuration($this->inputMock, $this->composerMock, new NullIO()); 390 | } 391 | 392 | private function createComposerMock() 393 | { 394 | $composer = $this->createMock(Composer::class); 395 | $packageInterface = $this->createMock(RootPackageInterface::class); 396 | 397 | $composer->method('getPackage')->willReturn($packageInterface); 398 | 399 | $packageInterface->method('getVersion')->willReturn('1.2.3'); 400 | $packageInterface->method('getExtra')->willReturnCallback(function() { 401 | switch ($this->localConfig) { 402 | case self::ComposerConfigSingle: 403 | return [ 404 | 'push' => array_replace([ 405 | "ignore" => $this->configIgnore, 406 | ], (!$this->splitConfig) ? [ 407 | 'url' => 'https://example.com', 408 | "username" => "push-username", 409 | "password" => "push-password", 410 | "type" => $this->extraConfigType, 411 | "ssl-verify" => $this->extraVerifySsl, 412 | ] : []) 413 | ]; 414 | case self::ComposerConfigMulti: 415 | return [ 416 | 'push' => array_replace_recursive([ 417 | [ 418 | 'name' => 'A', 419 | 'url' => 'https://a.com', 420 | ], 421 | [ 422 | 'name' => 'B', 423 | 'url' => 'https://b.com', 424 | ] 425 | ], (!$this->splitConfig) ? [ 426 | [ 427 | "username" => "push-username-a", 428 | "password" => "push-password-a", 429 | ], 430 | [ 431 | "username" => "push-username-b", 432 | "password" => "push-password-b", 433 | ] 434 | ] : []) 435 | ]; 436 | default: 437 | return []; 438 | } 439 | }); 440 | 441 | $pluginManager = $this->createMock(PluginManager::class); 442 | // PartialComposer is returned for 2.3.0+ composer 443 | $globalComposer = class_exists('Composer\PartialComposer') 444 | ? $this->createMock('Composer\PartialComposer') 445 | : $this->createMock('Composer\Composer'); 446 | $globalPackageInterface = $this->createMock(RootPackageInterface::class); 447 | 448 | $composer->method('getPluginManager')->willReturn($pluginManager); 449 | $pluginManager->method('getGlobalComposer')->willReturn($globalComposer); 450 | $globalComposer->method('getPackage')->willReturn($globalPackageInterface); 451 | 452 | $globalPackageInterface->method('getExtra')->willReturnCallback(function () { 453 | switch ($this->globalConfig) { 454 | case self::ComposerConfigSingle: 455 | return [ 456 | 'push' => array_replace([ 457 | 'url' => 'https://global.example.com', 458 | "username" => "global-push-username", 459 | "password" => "global-push-password", 460 | "type" => $this->extraConfigType, 461 | "ssl-verify" => $this->extraVerifySsl, 462 | ], (!$this->splitConfig) ? [ 463 | "ignore" => $this->configIgnore, 464 | ] : []) 465 | ]; 466 | case self::ComposerConfigMulti: 467 | return [ 468 | 'push' => array_replace_recursive([ 469 | [ 470 | 'name' => 'B', 471 | "username" => "global-push-username-b", 472 | "password" => "global-push-password-b", 473 | ], 474 | [ 475 | 'name' => 'A', 476 | "username" => "global-push-username-a", 477 | "password" => "global-push-password-a", 478 | ] 479 | ], (!$this->splitConfig) ? [ 480 | [ 481 | 'url' => 'https://global.b.com', 482 | ], 483 | [ 484 | 'url' => 'https://global.a.com', 485 | ] 486 | ] : []) 487 | ]; 488 | default: 489 | return []; 490 | } 491 | }); 492 | 493 | $packageInterface->method('getArchiveExcludes')->willReturnCallback(function() { 494 | return $this->composerPackageArchiveExcludes; 495 | }); 496 | 497 | return $composer; 498 | } 499 | 500 | private function assertArrayEquals($expected, $result) 501 | { 502 | try { 503 | 504 | $this->assertSameSize($expected, $result); 505 | } catch (ExpectationFailedException $e) { 506 | echo " Expected: "; 507 | print_r($expected); 508 | echo " Received: "; 509 | print_r($result); 510 | throw $e; 511 | } 512 | 513 | foreach ($expected as $e) { 514 | $this->assertContains($e, $result); 515 | } 516 | } 517 | } 518 | -------------------------------------------------------------------------------- /tests/RepositoryProvider/NexusProviderTest.php: -------------------------------------------------------------------------------- 1 | createBaseConfigurationMock(); 21 | 22 | $nexusProvider = new NexusProvider($configurationMock, new NullIO()); 23 | 24 | $this->assertEquals('https://example.com/my-repository/packages/upload/my-package/2.1.0', $nexusProvider->getUrl()); 25 | } 26 | 27 | /** 28 | * @covers \Elendev\ComposerPush\RepositoryProvider\NexusProvider::sendFile 29 | */ 30 | public function testSendFile() 31 | { 32 | $configurationMock = $this->createBaseConfigurationMock(); 33 | 34 | $mock = new MockHandler([ 35 | new Response(200), 36 | ]); 37 | $handlerStack = HandlerStack::create($mock); 38 | $client = new Client(['handler' => $handlerStack]); 39 | 40 | $nexusProvider = new NexusProvider($configurationMock, new NullIO(), $client); 41 | 42 | $nexusProvider->sendFile($this->getFilePath()); 43 | 44 | $request = $mock->getLastRequest(); 45 | 46 | $this->assertEquals('https', $request->getUri()->getScheme()); 47 | $this->assertEquals('example.com', $request->getUri()->getHost()); 48 | $this->assertEquals('/my-repository/packages/upload/my-package/2.1.0', $request->getUri()->getPath()); 49 | $this->assertEquals('PUT', $request->getMethod()); 50 | $this->assertEmpty($request->getHeader('Authorization')); 51 | $this->assertEquals('Simple test file to push.', $request->getBody()->getContents()); 52 | } 53 | 54 | /** 55 | * @covers \Elendev\ComposerPush\RepositoryProvider\NexusProvider::sendFile 56 | */ 57 | public function testSendFileWithAuthentication() 58 | { 59 | $configurationMock = $this->createBaseConfigurationMock(); 60 | 61 | $configurationMock->method('getOptionUsername')->willReturn('admin'); 62 | $configurationMock->method('getOptionPassword')->willReturn('password'); 63 | 64 | $mock = new MockHandler([ 65 | new Response(200), 66 | ]); 67 | $handlerStack = HandlerStack::create($mock); 68 | $client = new Client(['handler' => $handlerStack]); 69 | 70 | $nexusProvider = new NexusProvider($configurationMock, new NullIO(), $client); 71 | 72 | $nexusProvider->sendFile($this->getFilePath()); 73 | 74 | $request = $mock->getLastRequest(); 75 | 76 | $this->assertEquals('https', $request->getUri()->getScheme()); 77 | $this->assertEquals('example.com', $request->getUri()->getHost()); 78 | $this->assertEquals('/my-repository/packages/upload/my-package/2.1.0', $request->getUri()->getPath()); 79 | $this->assertEquals('PUT', $request->getMethod()); 80 | 81 | $this->assertEquals('Basic ' . base64_encode('admin:password'), $request->getHeader('Authorization')[0]); 82 | 83 | $this->assertEquals('Simple test file to push.', $request->getBody()->getContents()); 84 | } 85 | 86 | /** 87 | * @covers \Elendev\ComposerPush\RepositoryProvider\NexusProvider::sendFile 88 | */ 89 | public function testSendFileWithConfigCredentials() 90 | { 91 | $configurationMock = $this->createBaseConfigurationMock(); 92 | 93 | $configurationMock->method('get')->willReturnCallback(function($parameter) { 94 | switch($parameter) { 95 | case 'username': 96 | return 'admin'; 97 | case 'password': 98 | return 'my-password'; 99 | } 100 | }); 101 | 102 | $mock = new MockHandler([ 103 | new Response(200) 104 | ]); 105 | $handlerStack = HandlerStack::create($mock); 106 | $client = new Client(['handler' => $handlerStack]); 107 | 108 | $nexusProvider = new NexusProvider($configurationMock, new NullIO(), $client); 109 | 110 | $nexusProvider->sendFile($this->getFilePath()); 111 | 112 | $request = $mock->getLastRequest(); 113 | 114 | $this->assertEquals('https', $request->getUri()->getScheme()); 115 | $this->assertEquals('example.com', $request->getUri()->getHost()); 116 | $this->assertEquals('/my-repository/packages/upload/my-package/2.1.0', $request->getUri()->getPath()); 117 | $this->assertEquals('PUT', $request->getMethod()); 118 | 119 | $this->assertEquals('Basic ' . base64_encode('admin:my-password'), $request->getHeader('Authorization')[0]); 120 | 121 | $this->assertEquals('Simple test file to push.', $request->getBody()->getContents()); 122 | } 123 | 124 | /** 125 | * @covers \Elendev\ComposerPush\RepositoryProvider\NexusProvider::sendFile 126 | */ 127 | public function testSendFileWithAuthenticationCredentials() 128 | { 129 | $configurationMock = $this->createBaseConfigurationMock(); 130 | $ioMock = $this->createMock(IOInterface::class); 131 | $ioMock->method('hasAuthentication')->willReturn(true); 132 | $ioMock->method('getAuthentication')->willReturn([ 133 | 'username' => 'admin', 134 | 'password' => 'my-password' 135 | ]); 136 | 137 | $mock = new MockHandler([ 138 | new Response(200) 139 | ]); 140 | $handlerStack = HandlerStack::create($mock); 141 | $client = new Client(['handler' => $handlerStack]); 142 | 143 | $nexusProvider = new NexusProvider($configurationMock, $ioMock, $client); 144 | 145 | $nexusProvider->sendFile($this->getFilePath()); 146 | 147 | $request = $mock->getLastRequest(); 148 | 149 | $this->assertEquals('https', $request->getUri()->getScheme()); 150 | $this->assertEquals('example.com', $request->getUri()->getHost()); 151 | $this->assertEquals('/my-repository/packages/upload/my-package/2.1.0', $request->getUri()->getPath()); 152 | $this->assertEquals('PUT', $request->getMethod()); 153 | 154 | $this->assertEquals('Basic ' . base64_encode('admin:my-password'), $request->getHeader('Authorization')[0]); 155 | 156 | $this->assertEquals('Simple test file to push.', $request->getBody()->getContents()); 157 | } 158 | 159 | /** 160 | * @covers \Elendev\ComposerPush\RepositoryProvider\NexusProvider::sendFile 161 | */ 162 | public function testSendFileWithMultipleCredentials() 163 | { 164 | $configurationMock = $this->createBaseConfigurationMock(); 165 | 166 | $configurationMock->method('get')->willReturnCallback(function($parameter) { 167 | switch($parameter) { 168 | case 'username': 169 | return ''; 170 | case 'password': 171 | return ''; 172 | case 'access-token': 173 | return ''; 174 | } 175 | }); 176 | 177 | $mock = new MockHandler([ 178 | new Response(200) 179 | ]); 180 | $handlerStack = HandlerStack::create($mock); 181 | $client = new Client(['handler' => $handlerStack]); 182 | 183 | $nexusProvider = new NexusProvider($configurationMock, new NullIO(), $client); 184 | 185 | $nexusProvider->sendFile($this->getFilePath()); 186 | 187 | $request = $mock->getLastRequest(); 188 | 189 | $this->assertEquals('https', $request->getUri()->getScheme()); 190 | $this->assertEquals('example.com', $request->getUri()->getHost()); 191 | $this->assertEquals('/my-repository/packages/upload/my-package/2.1.0', $request->getUri()->getPath()); 192 | $this->assertEquals('PUT', $request->getMethod()); 193 | 194 | $this->assertEmpty($request->getHeader('Authorization')); // Fallback to "none" authentication 195 | 196 | $this->assertEquals('Simple test file to push.', $request->getBody()->getContents()); 197 | } 198 | 199 | /** 200 | * @covers \Elendev\ComposerPush\RepositoryProvider\NexusProvider::sendFile 201 | */ 202 | public function testSendFileWithBadCredentials() 203 | { 204 | $configurationMock = $this->createBaseConfigurationMock(); 205 | 206 | $configurationMock->method('get')->willReturnCallback(function($parameter) { 207 | switch($parameter) { 208 | case 'username': 209 | return 'admin'; 210 | case 'password': 211 | return 'my-password'; 212 | } 213 | }); 214 | 215 | $mock = new MockHandler([ 216 | new Response(401) 217 | ]); 218 | $handlerStack = HandlerStack::create($mock); 219 | $client = new Client(['handler' => $handlerStack]); 220 | 221 | $nexusProvider = new NexusProvider($configurationMock, new NullIO(), $client); 222 | 223 | $this->expectException(\Exception::class); 224 | $nexusProvider->sendFile($this->getFilePath()); 225 | } 226 | 227 | /** 228 | * @covers \Elendev\ComposerPush\RepositoryProvider\NexusProvider::sendFile 229 | */ 230 | public function testSendFileWithAccessToken() 231 | { 232 | $configurationMock = $this->createBaseConfigurationMock(); 233 | $ioMock = $this->createMock(IOInterface::class); 234 | $configurationMock->method('getAccessToken')->willReturn('my-token'); 235 | 236 | $mock = new MockHandler([ 237 | new Response(200) 238 | ]); 239 | $handlerStack = HandlerStack::create($mock); 240 | $client = new Client(['handler' => $handlerStack]); 241 | 242 | $nexusProvider = new NexusProvider($configurationMock, $ioMock, $client); 243 | 244 | $nexusProvider->sendFile($this->getFilePath()); 245 | 246 | $request = $mock->getLastRequest(); 247 | 248 | $this->assertEquals('https', $request->getUri()->getScheme()); 249 | $this->assertEquals('example.com', $request->getUri()->getHost()); 250 | $this->assertEquals('/my-repository/packages/upload/my-package/2.1.0', $request->getUri()->getPath()); 251 | $this->assertEquals('PUT', $request->getMethod()); 252 | 253 | $this->assertEquals('Bearer my-token', $request->getHeader('Authorization')[0]); 254 | 255 | $this->assertEquals('Simple test file to push.', $request->getBody()->getContents()); 256 | } 257 | 258 | /** 259 | * Create a base configuration mock 260 | * @return Configuration|\PHPUnit\Framework\MockObject\MockObject 261 | */ 262 | private function createBaseConfigurationMock() { 263 | $configurationMock = $this->createMock(Configuration::class); 264 | $configurationMock->method('getUrl')->willReturn('https://example.com/my-repository/'); 265 | $configurationMock->method('getPackageName')->willReturn('my-package'); 266 | $configurationMock->method('getVersion')->willReturn('2.1.0'); 267 | 268 | return $configurationMock; 269 | } 270 | 271 | /** 272 | * Return the test file path 273 | * @return string 274 | */ 275 | private function getFilePath() { 276 | return __DIR__ . '/testFile.txt'; 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /tests/RepositoryProvider/testFile.txt: -------------------------------------------------------------------------------- 1 | Simple test file to push. -------------------------------------------------------------------------------- /tests/ZipArchiverTest.php: -------------------------------------------------------------------------------- 1 | generationPath = tempnam(sys_get_temp_dir(),''); 15 | 16 | if (file_exists($this->generationPath)) { 17 | unlink($this->generationPath); 18 | } 19 | } 20 | 21 | public function tearDown(): void 22 | { 23 | $fs = new Filesystem(); 24 | $fs->remove($this->generationPath); 25 | } 26 | 27 | /** 28 | * @dataProvider zipArchiverProvider 29 | */ 30 | public function testArchiveDirectory(string $directory, array $expectedResult, string $subdirectory = null, array $ignore = []) { 31 | ZipArchiver::archiveDirectory( 32 | $directory, 33 | $this->generationPath, 34 | '0.0.1', 35 | $subdirectory, 36 | $ignore 37 | ); 38 | 39 | $this->assertArchiveContainsFiles($this->generationPath, $expectedResult); 40 | } 41 | 42 | /** 43 | * @dataProvider zipArchiverProvider 44 | */ 45 | public function testArchiveDirectoryWithDotFiles(string $directory, array $expectedResult, string $subdirectory = null, array $ignore = []) 46 | { 47 | $expectedResult[] = $subdirectory !== null ? $subdirectory . '/.foo/foo.txt' : '.foo/foo.txt'; 48 | 49 | ZipArchiver::archiveDirectory( 50 | $directory, 51 | $this->generationPath, 52 | '0.0.1', 53 | $subdirectory, 54 | $ignore, 55 | true 56 | ); 57 | 58 | 59 | $this->assertArchiveContainsFiles($this->generationPath, $expectedResult); 60 | } 61 | 62 | public static function zipArchiverProvider() { 63 | return [ 64 | [ 65 | __DIR__ . '/ZipArchiverTest/TypicalArchive', 66 | [ 67 | 'README.md', 68 | 'src/myFile.php', 69 | 'src/myOtherFile.php', 70 | 'src/tests/myFileTest.php', 71 | 'src/tests/myOtherFileTest.php', 72 | ], 73 | ], [ 74 | __DIR__ . '/ZipArchiverTest/TypicalArchive', 75 | [ 76 | 'typicalArchive/README.md', 77 | 'typicalArchive/src/myFile.php', 78 | 'typicalArchive/src/myOtherFile.php', 79 | 'typicalArchive/src/tests/myFileTest.php', 80 | 'typicalArchive/src/tests/myOtherFileTest.php', 81 | ], 82 | 'typicalArchive' 83 | ], [ 84 | __DIR__ . '/ZipArchiverTest/TypicalArchive', 85 | [ 86 | 'README.md', 87 | 'src/myFile.php', 88 | 'src/myOtherFile.php', 89 | ], 90 | null, 91 | [ 92 | 'src/tests' 93 | ] 94 | ], [ 95 | __DIR__ . '/ZipArchiverTest/TypicalArchive', 96 | [ 97 | 'README.md', 98 | 'src/myFile.php', 99 | 'src/tests/myFileTest.php', 100 | 'src/tests/myOtherFileTest.php', 101 | ], 102 | null, 103 | [ 104 | 'myOtherFile.php' 105 | ] 106 | ], 107 | ]; 108 | } 109 | 110 | /** 111 | * @covers \Elendev\ComposerPush\ZipArchiver::archiveDirectory 112 | * @dataProvider composerArchiverProvider 113 | */ 114 | public function testComposerArchiveDirectory(string $directory, array $expectedResult, $subdirectory, string $version) { 115 | ZipArchiver::archiveDirectory( 116 | $directory, 117 | $this->generationPath, 118 | $version, 119 | $subdirectory 120 | ); 121 | 122 | $this->assertArchiveContainsFiles($this->generationPath, $expectedResult); 123 | $this->assertComposerJsonVersion($this->generationPath, $subdirectory, $version); 124 | } 125 | 126 | public static function composerArchiverProvider() { 127 | return [ 128 | [ 129 | __DIR__ . '/ZipArchiverTest/ComposerJsonArchive', 130 | [ 131 | 'composer.json', 132 | 'src/myFile.php', 133 | 'src/myOtherFile.php', 134 | ], 135 | null, 136 | '0.0.1', 137 | ], 138 | [ 139 | __DIR__ . '/ZipArchiverTest/ComposerJsonArchive', 140 | [ 141 | 'composer.json', 142 | 'src/myFile.php', 143 | 'src/myOtherFile.php', 144 | ], 145 | null, 146 | 'v1.0.0', 147 | ], 148 | [ 149 | __DIR__ . '/ZipArchiverTest/ComposerJsonArchive', 150 | [ 151 | 'composer-json-archive/composer.json', 152 | 'composer-json-archive/src/myFile.php', 153 | 'composer-json-archive/src/myOtherFile.php', 154 | ], 155 | 'composer-json-archive', 156 | 'v2.0.0', 157 | ], 158 | ]; 159 | } 160 | 161 | 162 | /** 163 | * Assert that the given archive contains the files 164 | * @param string $archivePath 165 | * @param array $files 166 | */ 167 | private function assertArchiveContainsFiles(string $archivePath, array $files) { 168 | $archive = new \ZipArchive(); 169 | $archive->open($archivePath); 170 | 171 | $this->assertEquals(count($files), $archive->numFiles, 'Not the correct amount of files in the archive ' . $archivePath); 172 | 173 | for ($i = 0; $i < $archive->numFiles; $i ++) { 174 | $entry = $archive->statIndex($i); 175 | $this->assertContains($entry['name'], $files); 176 | } 177 | } 178 | 179 | /** 180 | * @param string $archivePath 181 | * @param string $version 182 | */ 183 | private function assertComposerJsonVersion(string $archivePath, $subDirectory, string $version) 184 | { 185 | $archive = new \ZipArchive(); 186 | $archive->open($archivePath); 187 | 188 | $filePath = ($subDirectory ? $subDirectory . '/' : '') . 'composer.json'; 189 | 190 | $content = json_decode($archive->getFromName($filePath)); 191 | 192 | $this->assertEquals($version, $content->version); 193 | 194 | $archive->close(); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /tests/ZipArchiverTest/ComposerJsonArchive/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elendev/test-composer-json-archive", 3 | "require": {} 4 | } 5 | -------------------------------------------------------------------------------- /tests/ZipArchiverTest/ComposerJsonArchive/src/myFile.php: -------------------------------------------------------------------------------- 1 |