├── .env.dist ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── tests.yml ├── .gitignore ├── LICENSE ├── README.md ├── bin └── tailor ├── composer.json ├── conf └── ExcludeFromPackaging.php ├── phpunit.xml.dist ├── src ├── Command │ ├── AbstractClientRequestCommand.php │ ├── Auth │ │ ├── CreateTokenCommand.php │ │ ├── RefreshTokenCommand.php │ │ └── RevokeTokenCommand.php │ └── Extension │ │ ├── CreateExtensionArtefactCommand.php │ │ ├── DeleteExtensionCommand.php │ │ ├── ExtensionDetailsCommand.php │ │ ├── ExtensionVersionsCommand.php │ │ ├── FindExtensionsCommand.php │ │ ├── RegisterExtensionCommand.php │ │ ├── SetExtensionVersionCommand.php │ │ ├── TransferExtensionCommand.php │ │ ├── UpdateExtensionCommand.php │ │ ├── UploadExtensionVersionCommand.php │ │ └── VersionDetailsCommand.php ├── Dto │ ├── Messages.php │ └── RequestConfiguration.php ├── Environment │ └── Variables.php ├── Exception │ ├── ExtensionKeyMissingException.php │ ├── FormDataProcessingException.php │ ├── InvalidComposerJsonException.php │ └── RequiredConfigurationMissing.php ├── Filesystem │ ├── ComposerReader.php │ ├── Directory.php │ └── VersionReplacer.php ├── Formatter │ └── ConsoleFormatter.php ├── Helper │ └── CommandHelper.php ├── HttpClientFactory.php ├── Output │ ├── OutputPart.php │ ├── OutputPartInterface.php │ └── OutputParts.php ├── Service │ ├── RequestService.php │ └── VersionService.php ├── Validation │ ├── EmConfValidationError.php │ ├── EmConfVersionValidator.php │ └── VersionValidator.php └── Writer │ └── ConsoleWriter.php └── tests ├── Unit ├── Environment │ └── VariablesTest.php ├── Filesystem │ ├── ComposerReaderTest.php │ └── VersionReplacerTest.php ├── Fixtures │ ├── Composer │ │ ├── composer_no_extension_key.json │ │ └── composer_with_extension_key.json │ ├── Documentation │ │ ├── Settings.cfg │ │ └── guides.xml │ ├── EmConf │ │ ├── emconf_invalid.php │ │ ├── emconf_no_structure.php │ │ ├── emconf_no_version.php │ │ ├── emconf_valid.php │ │ └── emconf_valid_string_array_key.php │ └── ExcludeFromPackaging │ │ ├── config_invalid.php │ │ └── config_valid.php ├── Formatter │ └── ConsoleFormatterTest.php ├── Helper │ └── CommandHelperTest.php ├── HttpClientFactoryTest.php ├── Service │ └── VersionServiceTest.php └── Validation │ ├── EmConfVersionValidatorTest.php │ └── VersionValidatorTest.php └── bootstrap.php /.env.dist: -------------------------------------------------------------------------------- 1 | TYPO3_REMOTE_BASE_URI=https://extensions.typo3.org/ 2 | TYPO3_API_VERSION=v1 3 | TYPO3_API_TOKEN= 4 | TYPO3_API_USERNAME= 5 | TYPO3_API_PASSWORD= 6 | TYPO3_EXTENSION_KEY= 7 | TYPO3_EXCLUDE_FROM_PACKAGING= 8 | TYPO3_DISABLE_DOCS_VERSION_UPDATE= 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report to help us improving tailor 4 | title: "[BUG] Something does not work properly" 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Summary / Description 11 | 12 | Short description of the bug. 13 | 14 | ## Version 15 | 16 | Version in which the bug occurs. 17 | 18 | ## Steps to reproduce 19 | 20 | How one can reproduce the issue - **this is very important** 21 | 22 | ## Expected behaviour 23 | 24 | What you expected to happen. 25 | 26 | ## Actual behavior 27 | 28 | What actually happens. 29 | 30 | ### Additional 31 | 32 | **Possible fix** 33 | 34 | If you can, link to the line of code that might be responsible 35 | for the problem or create a pull request if you have created 36 | the fix already. 37 | 38 | ***Miscellaneous*** 39 | 40 | Add any other information like screenshots. You can also paste 41 | any relevant logs. Please use code blocks (```) to format console 42 | output, logs, and code as it's very hard to read otherwise. 43 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea to help us improving tailor 4 | title: "[FEATURE] Suggestion for some new feature" 5 | labels: feature 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Summary / Description 11 | 12 | Summarize your idea for a new feature. 13 | 14 | ## Describe the solution you'd like 15 | 16 | A clear and concise description of what you want to happen. 17 | 18 | ## Describe alternatives you've considered 19 | 20 | A clear and concise description of any alternative solutions or 21 | features you've considered. 22 | 23 | ## Possible solution 24 | 25 | If you can provide the feature by yourself, please either 26 | include the corresponding code or even better create a 27 | pull request. 28 | 29 | If you paste code, please use code blocks (```) for formatting 30 | since it's otherwise very hard to read. 31 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | on: [push, pull_request] 3 | jobs: 4 | tests: 5 | name: Tests with PHP ${{ matrix.php }} 6 | runs-on: ubuntu-22.04 7 | strategy: 8 | matrix: 9 | php: ["7.2", "7.3", "7.4", "8.0", "8.1", "8.2", "8.3", "8.4"] 10 | steps: 11 | - name: Checkout repository 12 | uses: actions/checkout@v3 13 | 14 | - name: Setup PHP 15 | uses: shivammathur/setup-php@v2 16 | with: 17 | php-version: ${{ matrix.php }} 18 | extensions: zip 19 | tools: composer:v2 20 | 21 | - name: Get composer cache directory 22 | id: composer-cache 23 | run: echo "composer_cache_dir=$(composer config cache-files-dir)" >> $GITHUB_ENV 24 | 25 | - name: Cache composer dependencies 26 | uses: actions/cache@v3 27 | with: 28 | path: ${{ env.composer_cache_dir }} 29 | key: ${{ runner.os }}-php-${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} 30 | restore-keys: ${{ runner.os }}-php-${{ matrix.php }}-composer- 31 | 32 | - name: Install dependencies 33 | run: composer install --prefer-dist --no-progress --no-suggest --optimize-autoloader 34 | 35 | - name: Run unit tests 36 | run: composer tests:unit 37 | 38 | codestyle: 39 | name: Code style 40 | runs-on: ubuntu-22.04 41 | strategy: 42 | matrix: 43 | php: ["8.3"] 44 | steps: 45 | - name: Checkout repository 46 | uses: actions/checkout@v3 47 | 48 | - name: Setup PHP 49 | uses: shivammathur/setup-php@v2 50 | with: 51 | php-version: ${{ matrix.php }} 52 | extensions: zip 53 | tools: composer:v2 54 | 55 | - name: Get composer cache directory 56 | id: composer-cache 57 | run: echo "composer_cache_dir=$(composer config cache-files-dir)" >> $GITHUB_ENV 58 | 59 | - name: Cache composer dependencies 60 | uses: actions/cache@v3 61 | with: 62 | path: ${{ env.composer_cache_dir }} 63 | key: ${{ runner.os }}-php-${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} 64 | restore-keys: ${{ runner.os }}-php-${{ matrix.php }}-composer- 65 | 66 | - name: Install dependencies 67 | run: composer install --prefer-dist --no-progress --no-suggest --optimize-autoloader 68 | 69 | - name: Run cgl 70 | run: composer cs 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.DS_Store 2 | /.env 3 | /.idea/ 4 | /.php-cs-fixer.cache 5 | /.php_cs.cache 6 | /.phpunit.result.cache 7 | /composer.lock 8 | /vendor 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Benni Mack 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tailor 2 | 3 | ![Tests](https://github.com/TYPO3/tailor/workflows/tests/badge.svg) 4 | 5 | Tailor is a CLI application to help you maintain your extensions. 6 | Tailor talks with the [TER REST API][rest-api] and enables you to 7 | register new keys, update extension information and publish new 8 | versions to the [extension repository][ter]. 9 | 10 | ## Contents 11 | 12 | - [Prerequisites](#prerequisites) 13 | - [Installation](#installation) 14 | - [Usage](#usage) 15 | - [Manage your personal access token](#manage-your-personal-access-token) 16 | - [Register a new extension key](#register-a-new-extension-key) 17 | - [Update the version in your extension files](#update-the-version-in-your-extension-files) 18 | - [Publish a new version of an extension to TER](#publish-a-new-version-of-an-extension-to-ter) 19 | - [Create a local artefact of an extension](#create-a-local-artefact-of-an-extension) 20 | - [Update extension meta information](#update-extension-meta-information) 21 | - [Transfer ownership of an extension to another user](#transfer-ownership-of-an-extension-to-another-user) 22 | - [Delete / abandon an extension](#delete--abandon-an-extension) 23 | - [Find and filter extensions on TER](#find-and-filter-extensions-on-ter) 24 | - [Specific extension details](#specific-extension-details) 25 | - [Specific extension version details](#specific-extension-version-details) 26 | - [Details for all versions of an extension](#details-for-all-versions-of-an-extension) 27 | - [Publish a new version using tailor locally](#publish-a-new-version-using-tailor-locally) 28 | - [Publish a new version using your CI](#publish-a-new-version-using-your-ci) 29 | - [Github actions workflow](#github-actions-workflow) 30 | - [GitLab pipeline](#gitlab-pipeline) 31 | - [Exclude paths from packaging](#exclude-paths-from-packaging) 32 | - [Overview of all available commands](#overview-of-all-available-commands) 33 | - [General options for all commands](#general-options-for-all-commands) 34 | - [Author & License](#author--license) 35 | 36 | ## Prerequisites 37 | 38 | The [TER REST API][rest-api] can be accessed providing a personal 39 | access token. You can create such token either on 40 | [https://extensions.typo3.org/][ter] after you've logged in, or 41 | directly using Tailor. 42 | 43 | > [!IMPORTANT] 44 | > To create, refresh or revoke an access token with Tailor, 45 | > you have to add your TYPO3.org credentials (see below). Even if it 46 | > is possible to execute all commands using the TYPO3.org credentials 47 | > for authentication, it is highly discouraged. That's why we have 48 | > built token based authentication for the [TER][ter]. 49 | 50 | Provide your credentials by either creating a `.env` file in the 51 | project root folder or setting environment variables through your 52 | system to this PHP script: 53 | 54 | ```bash 55 | TYPO3_API_TOKEN= 56 | TYPO3_API_USERNAME= 57 | TYPO3_API_PASSWORD= 58 | ``` 59 | 60 | > [!NOTE] 61 | > For an overview of all available environment variables, 62 | > have a look at the `.env.dist` file. 63 | 64 | > [!TIP] 65 | > You can also add environment variables directly on 66 | > executing a command. This overrides any variable, defined in 67 | > the `.env` file. 68 | 69 | Example: 70 | 71 | ```bash 72 | TYPO3_API_TOKEN="someToken" TYPO3_EXTENSION_KEY="ext_key" bin/tailor ter:details 73 | ``` 74 | 75 | This will display the extension details for extension `ext_key` if 76 | `someToken` is valid (not expired/revoked and having at least the 77 | `extension:read` scope assigned). 78 | 79 | ## Installation 80 | 81 | Use Tailor as a dev dependency via composer of your extensions: 82 | 83 | ```bash 84 | composer req --dev typo3/tailor 85 | ``` 86 | 87 | ## Usage 88 | 89 | All commands, requesting the TER API, provide the `-r, --raw` 90 | option. If set, the raw result will be returned. This can be 91 | used for further processing e.g. by using some JSON processor. 92 | 93 | Most of the commands require an extension key to work with. 94 | There are multiple possibilities to provide an extension key. 95 | These are - in the order in which they are checked: 96 | 97 | - As argument, e.g. `./vendor/bin/tailor ter:details my_key` 98 | - As environment variable, `TYPO3_EXTENSION_KEY=my_key` 99 | - In your `composer.json`, `[extra][typo3/cms][extension-key] = 'my_key'` 100 | 101 | This means, even if you have an extension key defined globally, 102 | either as environment variable or in your `composer.json`, you 103 | can still run all commands for different extensions by adding 104 | the desired extension key as argument to the command. 105 | 106 | > [!NOTE] 107 | > If no extension key is defined, neither as an argument, 108 | > as environment variable, nor in your `composer.json`, commands 109 | > which require an extension key to be set, will throw an exception. 110 | 111 | ### Manage your personal access token 112 | 113 | Use the `ter:token:create` command to create a new token: 114 | 115 | ```bash 116 | ./vendor/bin/tailor ter:token:create --name="token for my_extension" --extensions=my_extension 117 | ``` 118 | 119 | The result will look like this: 120 | 121 | ```text 122 | Token type: bearer 123 | Access token: eyJ0eXAOiEJKV1QiLCJhb 124 | Refresh token: eyJ0eXMRxHRaF4hIVrEtu 125 | Expires in: 604800 126 | Scope: extension:read,extension:write 127 | Extensions: my_extension 128 | ``` 129 | 130 | As you can see, this will create an access token which is only 131 | valid for the extension `my_extension`. The scopes are set to 132 | `extension:read,extension:write` since this is the default if 133 | option `--scope` is not provided. The same applies to the 134 | expiration date which can be set with the option `--expires`. 135 | 136 | If the token threatens to expire, refresh it with `ter:token:refresh`: 137 | 138 | ```bash 139 | ./vendor/bin/tailor ter:token:refresh eyJ0eXMRxHRaF4hIVrEtu 140 | ``` 141 | 142 | This will generate new access and refresh tokens with the same 143 | options, initially set on creation. 144 | 145 | To revoke an access token irretrievably, use `ter:token:revoke`: 146 | 147 | ```bash 148 | ./vendor/bin/tailor ter:token:revoke eyJ0eXAOiEJKV1QiLCJhb 149 | ``` 150 | 151 | ### Register a new extension key 152 | 153 | To register a new extension, use `ter:register` by providing 154 | your desired extension key as argument: 155 | 156 | ```bash 157 | ./vendor/bin/tailor ter:register my_extension 158 | ``` 159 | 160 | This registers the key `my_extension` and returns following 161 | confirmation: 162 | 163 | ```http 164 | Key: my_extension 165 | Owner: your_username 166 | ``` 167 | 168 | ### Update the version in your extension files 169 | 170 | Prior to publishing a new version, you have to update the 171 | version in your extensions `ext_emconf.php` file. This can 172 | be done using the `set-version` command. 173 | 174 | ```bash 175 | ./vendor/bin/tailor set-version 1.2.0 176 | ``` 177 | 178 | If your extension also contains a `Documentation/guides.xml` 179 | or `Documentation/Settings.cfg` file, the command will also 180 | update the `release` and `version` information in it. You 181 | can disable this feature by either using `--no-docs` or by 182 | setting the environment variable `TYPO3_DISABLE_DOCS_VERSION_UPDATE=1`. 183 | 184 | > [!TIP] 185 | > It's also possible to use the `--path` option to 186 | > specify the location of your extension. If not given, your 187 | > current working directory is search for the `ext_emconf.php` 188 | > file. 189 | 190 | > [!NOTE] 191 | > The version will only be updated if already present 192 | > in your `ext_emconf.php`. It won't be added by this command. 193 | 194 | ### Publish a new version of an extension to TER 195 | 196 | You can publish a new version of your extension using the 197 | `ter:publish` command. Therefore, provide the extension key 198 | and version number as arguments followed by the path to the 199 | extension directory or an artefact (a zipped version of your 200 | extension). The latter can be either local or a remote file. 201 | 202 | Using `--path`: 203 | 204 | ```bash 205 | ./vendor/bin/tailor ter:publish 1.2.0 my_extension --path=/path/to/my_extension 206 | ``` 207 | 208 | Using a local `--artefact`: 209 | 210 | ```bash 211 | ./vendor/bin/tailor ter:publish 1.2.0 my_extension --artefact=/path/to/any-zip-file/my_extension.zip 212 | ``` 213 | 214 | Using a remote `--artefact`: 215 | 216 | ```bash 217 | ./vendor/bin/tailor ter:publish 1.2.0 my_extension --artefact=https://github.com/my-name/my_extension/archive/1.2.0.zip 218 | ``` 219 | 220 | Using the root direcotry: 221 | 222 | ```bash 223 | ./vendor/bin/tailor ter:publish 1.2.0 my_extension 224 | ``` 225 | 226 | If the extension key is defined as environment variable or 227 | in your `composer.json`, it can also be skipped. So using the 228 | current root directory the whole command simplifies to: 229 | 230 | ```bash 231 | ./vendor/bin/tailor ter:publish 1.2.0 232 | ``` 233 | 234 | > [!IMPORTANT] 235 | > A couple of directories and files are excluded from packaging 236 | > by default. Read more about 237 | > [excluding paths from packaging](#exclude-paths-from-packaging) 238 | > below. 239 | 240 | > [!NOTE] 241 | > The REST API, just like the the [TER][ter], requires 242 | > an upload comment to be set. This can be achieved using the 243 | > `--comment` option. If not set, Tailor will automatically use 244 | > `Updated extension to ` as comment. 245 | 246 | ### Create a local artefact of an extension 247 | 248 | You can generate a local artefact of your extension using the 249 | `create-artefact` command. This will generate a zip archive 250 | ready to be uploaded to TER (which is not covered by this 251 | command, have a look at the [`ter:publish`](#publish-a-new-version-of-an-extension-to-ter) 252 | command instead). 253 | 254 | Provide the version number and extension key as arguments 255 | followed by the path to the extension directory or an artefact 256 | (a zipped version of your extension). The latter can be either 257 | local or a remote file. 258 | 259 | Using `--path`: 260 | 261 | ```bash 262 | ./vendor/bin/tailor create-artefact 1.2.0 my_extension --path=/path/to/my_extension 263 | ``` 264 | 265 | Using a local `--artefact`: 266 | 267 | ```bash 268 | ./vendor/bin/tailor create-artefact 1.2.0 my_extension --artefact=/path/to/any-zip-file/my_extension.zip 269 | ``` 270 | 271 | Using a remote `--artefact`: 272 | 273 | ```bash 274 | ./vendor/bin/tailor create-artefact 1.2.0 my_extension --artefact=https://github.com/my-name/my_extension/archive/1.2.0.zip 275 | ``` 276 | 277 | Using the root directory: 278 | 279 | ```bash 280 | ./vendor/bin/tailor create-artefact 1.2.0 my_extension 281 | ``` 282 | 283 | If the extension key is defined as environment variable or 284 | in your `composer.json`, it can also be skipped. So using the 285 | current root directory the whole command simplifies to: 286 | 287 | ```bash 288 | ./vendor/bin/tailor create-artefact 1.2.0 289 | ``` 290 | 291 | > [!IMPORTANT] 292 | > A couple of directories and files are excluded from packaging 293 | > by default. Read more about 294 | > [excluding paths from packaging](#exclude-paths-from-packaging) 295 | > below. 296 | 297 | ### Update extension meta information 298 | 299 | You can update the extension meta information, such as the 300 | composer name, or the associated tags with the `ter:update` 301 | command. 302 | 303 | To update the composer name: 304 | 305 | ```bash 306 | ./vendor/bin/tailor ter:update my_extension --composer=vender/my_extension 307 | ``` 308 | 309 | To update the tags: 310 | 311 | ```bash 312 | ./vendor/bin/tailor ter:update my_extension --tags=some-tag,another-tag 313 | ``` 314 | 315 | Please use `./vendor/bin/tailor ter:update -h` to see the full 316 | list of available options. 317 | 318 | > [!IMPORTANT] 319 | > All options set with this command will overwrite the 320 | > existing data. Therefore, if you, for example, just want to add 321 | > another tag, you have to add the current ones along with the new 322 | > one. You can use `ter:details` to get the current state. 323 | 324 | ### Transfer ownership of an extension to another user 325 | 326 | It's possible to transfer one of your extensions to another user. 327 | Therefore, use the `ter:transfer` command providing the extension 328 | key to be transferred and the TYPO3.org username of the recipient. 329 | 330 | Since you won't have any access to the extension afterwards, the 331 | command asks for your confirmation before sending the order to 332 | the REST API. 333 | 334 | ```bash 335 | ./vendor/bin/tailor ter:transfer some_user my_extension 336 | ``` 337 | 338 | This transfers the extension `my_extension` to the user 339 | `some_user` and returns following confirmation: 340 | 341 | ```http 342 | Key: my_extension 343 | Owner: some_user 344 | ``` 345 | 346 | > [!TIP] 347 | > For automated workflows the confirmation can be 348 | > skipped with the ``-n, --no-interaction`` option. 349 | 350 | ### Delete / abandon an extension 351 | 352 | You can easily delete / abandon extensions with Tailor using 353 | the `ter:delete` command. This either removes the extension 354 | entirely or just abandons it if the extension still has public 355 | versions. 356 | 357 | Since you won't have any access to the extension afterwards, 358 | the command asks for your confirmation before sending the order 359 | to the REST API. 360 | 361 | ```bash 362 | ./vendor/bin/tailor ter:delete my_extension 363 | ``` 364 | 365 | This will delete / abandon the extension `my_extension`. 366 | 367 | > [!TIP] 368 | > For automated workflows the confirmation can be 369 | > skipped with the ``-n, --no-interaction`` option. 370 | 371 | ### Find and filter extensions on TER 372 | 373 | Tailor can't only be used for managing your extensions but 374 | also to find others. Therefore, use `ter:find` by adding some 375 | filters: 376 | 377 | ```bash 378 | ./vendor/bin/tailor ter:find 379 | ./vendor/bin/tailor ter:find --typo3-version=9 380 | ./vendor/bin/tailor ter:find --typo3-author=some_user 381 | ``` 382 | 383 | First command will find all public extensions. The second 384 | and third one will only return extensions which match the 385 | filter. In this case being compatible with TYPO3 version 386 | `9` or owned by `some_user`. 387 | 388 | To limit / paginate the result, you can use the options 389 | `--page` and `--per_page`: 390 | 391 | ```bash 392 | ./vendor/bin/tailor ter:find --page=3 --per_page=20 393 | ``` 394 | 395 | #### Specific extension details 396 | 397 | You can also request more details about a specific extension 398 | using the `ter:details` command: 399 | 400 | ```bash 401 | ./vendor/bin/tailor ter:details my_extension 402 | ``` 403 | 404 | This will return details about the extension `my_extension` 405 | like the current version, the author, some meta information 406 | and more. Similar to the extension detail page on 407 | [extension.typo3.org][ter]. 408 | 409 | #### Specific extension version details 410 | 411 | If you like to get details about a specific version of an 412 | extension, `ter:version` can be used: 413 | 414 | ```bash 415 | ./vendor/bin/tailor ter:version 1.0.0 my_extension 416 | ``` 417 | 418 | This will return details about version `1.0.0` of extension 419 | `my_extension`. 420 | 421 | #### Details for all versions of an extension 422 | 423 | You can also get the details for all versions of an extension 424 | with `ter:versions`: 425 | 426 | ```bash 427 | ./vendor/bin/tailor ter:versions my_extension 428 | ``` 429 | 430 | This will return the details for all version of the extension 431 | `my_extension`. 432 | 433 | ## Publish a new version using tailor locally 434 | 435 | **Step 1: Update the version in your extension files** 436 | 437 | ```bash 438 | ./vendor/bin/tailor set-version 1.5.0 439 | ``` 440 | 441 | **Step 2: Commit the changes and add a tag** 442 | 443 | ```bash 444 | git commit -am "[RELEASE] A new version was published" 445 | git tag -a 1.5.0 446 | ``` 447 | 448 | **Step 3: Push this to your remote repository** 449 | 450 | ```bash 451 | git push origin --tags 452 | ``` 453 | 454 | **Step 4: Push this version to TER** 455 | 456 | ```bash 457 | ./vendor/bin/tailor ter:publish 1.5.0 458 | ``` 459 | 460 | > [!NOTE] 461 | > Both `set-version` and `ter:publish` provide options 462 | > to specify the location of your extension. If, like in the example 463 | > above, non is set, Tailor automatically uses your current working 464 | > directory. 465 | 466 | ## Publish a new version using your CI 467 | 468 | You can also integrate tailor into you GitHub workflow respectively 469 | your GitLab pipeline. Therefore, **Step 1**, **Step 2** and **Step 3** 470 | from the above example are the same. **Step 4** could then be 471 | done by your integration. 472 | 473 | Please have a look at the following examples describing how 474 | such integration could look like for GitHub workflows and 475 | GitLab pipelines. 476 | 477 | ### Github actions workflow 478 | 479 | The workflow will only be executed when pushing a new tag. 480 | This can either be done using **Step 3** from above example 481 | or by creating a new GitHub release which will also add a 482 | new tag. 483 | 484 | The workflow furthermore requires the GitHub secrets `TYPO3_EXTENSION_KEY` 485 | and `TYPO3_API_TOKEN` to be set. Add them at "Settings -> Secrets -> New 486 | repository secret". 487 | 488 | > [!NOTE] 489 | > If your `composer.json` file contains the extension key at 490 | > `[extra][typo3/cms][extension-key] = 'my_key'` (this is good practice anyway), 491 | > the `TYPO3_EXTENSION_KEY` secret and assignment in the below GitHub action 492 | > example is not needed, tailor will pick it up. 493 | 494 | The version is automatically fetched from the tag and 495 | validated to match the required pattern. 496 | 497 | The commit message from **Step 2** is used as the release 498 | comment. If it's empty, a static text will be used. 499 | 500 | To see the following workflow in action, please have a 501 | look at the [tailor_ext][tailor-ext] example extension. 502 | 503 | ```yaml 504 | name: publish 505 | on: 506 | push: 507 | tags: 508 | - '*' 509 | jobs: 510 | publish: 511 | name: Publish new version to TER 512 | # use folliwing if tags begins with `v` 513 | # if: startsWith(github.ref, 'refs/tags/v') 514 | if: startsWith(github.ref, 'refs/tags/') 515 | runs-on: ubuntu-20.04 516 | env: 517 | TYPO3_EXTENSION_KEY: ${{ secrets.TYPO3_EXTENSION_KEY }} 518 | TYPO3_API_TOKEN: ${{ secrets.TYPO3_API_TOKEN }} 519 | steps: 520 | - name: Checkout repository 521 | uses: actions/checkout@v3 522 | 523 | - name: Check tag 524 | run: | 525 | # use ^refs/tags/v[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}$ when tag is prefixed with v 526 | if ! [[ ${{ github.ref }} =~ ^refs/tags/[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}$ ]]; then 527 | exit 1 528 | fi 529 | 530 | - name: Get version 531 | id: get-version 532 | run: echo "version=${GITHUB_REF/refs\/tags\//}" >> $GITHUB_ENV 533 | 534 | - name: Get comment 535 | id: get-comment 536 | run: | 537 | # If tag begins with `v` use: 538 | # readonly local comment=$(git tag -l v${{ env.version }} --format '%(contents)) 539 | readonly local comment=$(git tag -l ${{ env.version }} --format '%(contents)) 540 | 541 | if [[ -z "${comment// }" ]]; then 542 | echo "comment=Released version ${{ env.version }} of ${{ env.TYPO3_EXTENSION_KEY }}" >> $GITHUB_ENV 543 | else 544 | { 545 | echo 'comment<> "$GITHUB_ENV" 549 | fi 550 | 551 | - name: Setup PHP 552 | uses: shivammathur/setup-php@v2 553 | with: 554 | php-version: 7.4 555 | extensions: intl, mbstring, json, zip, curl 556 | tools: composer:v2 557 | 558 | - name: Install tailor 559 | run: composer global require typo3/tailor --prefer-dist --no-progress --no-suggest 560 | 561 | - name: Publish to TER 562 | run: php ~/.composer/vendor/bin/tailor ter:publish --comment "${{ env.comment }}" ${{ env.version }} 563 | ``` 564 | 565 | > [!IMPORTANT] 566 | > If you're using tags with a leading `v` the above example needs to be adjusted. 567 | 568 | 1. The regular expression in step **Check tag** should be: 569 | 570 | ```regexp 571 | ^refs/tags/v[0-9]{1,3}.[0-9]{1,3}.[0-9]{1,3}$ 572 | ``` 573 | 574 | 2. The output format in step **Get version** should be: 575 | 576 | ```bash 577 | ${GITHUB_REF#refs/tags/v} 578 | ``` 579 | 580 | 3. The variable declaration in step **Get comment** should be: 581 | 582 | ```bash 583 | $(git tag -l v${{ env.version }} --format '%(contents)) 584 | ``` 585 | 586 | #### GitHub actions from TYPO3 community 587 | 588 | Additionally, to further simplify your workflow, you can also use the 589 | [typo3-uploader-ter][typo3-uploader-ter] GitHub action from TYPO3 community 590 | member Tomas Norre. For more information about the usage, please refer to the 591 | corresponding [README][typo3-uploader-ter-readme]. 592 | 593 | ### GitLab pipeline 594 | 595 | The job will only be executed when pushing a new tag. 596 | The upload comment is taken from the message in the tag. 597 | 598 | The job furthermore requires the GitLab variables 599 | `TYPO3_EXTENSION_KEY` and `TYPO3_API_TOKEN` to be set. 600 | 601 | > [!NOTE] 602 | > If your `composer.json` file contains your extension 603 | > key, you can remove the `TYPO3_EXTENSION_KEY` variable, the 604 | > check and the assignment in the GitLab pipeline, since Tailor 605 | > automatically fetches this key then. 606 | 607 | The variable `CI_COMMIT_TAG` is set by GitLab automatically. 608 | 609 | ```yaml 610 | "Publish new version to TER": 611 | stage: release 612 | image: composer:2 613 | only: 614 | - tags 615 | before_script: 616 | - composer global require typo3/tailor 617 | script: 618 | - > 619 | if [ -n "$CI_COMMIT_TAG" ] && [ -n "$TYPO3_API_TOKEN" ] && [ -n "$TYPO3_EXTENSION_KEY" ]; then 620 | echo -e "Preparing upload of release ${CI_COMMIT_TAG} to TER\n" 621 | # Cleanup before we upload 622 | git reset --hard HEAD && git clean -fx 623 | # Upload 624 | TAG_MESSAGE=`git tag -n10 -l $CI_COMMIT_TAG | sed 's/^[0-9.]*[ ]*//g'` 625 | echo "Uploading release ${CI_COMMIT_TAG} to TER" 626 | /tmp/vendor/bin/tailor ter:publish --comment "$TAG_MESSAGE" "$CI_COMMIT_TAG" "$TYPO3_EXTENSION_KEY" 627 | fi; 628 | ``` 629 | 630 | ## Exclude paths from packaging 631 | 632 | A couple of directories and files are excluded 633 | from packaging by default. You can find the configuration in 634 | [`conf/ExcludeFromPackaging.php`](conf/ExcludeFromPackaging.php). 635 | 636 | If you like, you can also use a custom configuration. Just add the 637 | path to your custom configuration file to the environment variable 638 | `TYPO3_EXCLUDE_FROM_PACKAGING`. This file must return an 639 | `array` with the keys `directories` and `files` on root level. 640 | 641 | ## Overview of all available commands 642 | 643 | | Commands | Arguments | Options | Description | 644 | |-----------------------|-----------------------------------|-------------------------------------------------------------------------------------------------------|--------------------------------------------------------| 645 | | ``set-version`` | ``version`` | ``--path``
``--no-docs`` | Update the version in extension files | 646 | | ``ter:delete`` | ``extensionkey`` | | Delete an extension. | 647 | | ``ter:details`` | ``extensionkey`` | | Fetch details about an extension. | 648 | | ``ter:find`` | | ``--page``
``--per-page``
``--author``
``--typo3-version`` | Fetch a list of extensions from TER. | 649 | | ``ter:publish`` | ``version``
``extensionkey`` | ``--path``
``--artefact``
``--comment`` | Publishes a new version of an extension to TER. | 650 | | ``create-artefact`` | ``version``
``extensionkey`` | ``--path``
``--artefact`` | Create an artefact file (zip archive) of an extension. | 651 | | ``ter:register`` | ``extensionkey`` | | Register a new extension key in TER. | 652 | | ``ter:token:create`` | | ``--name``
``--expires``
``--scope``
``--extensions`` | Request an access token for the TER. | 653 | | ``ter:token:refresh`` | ``token`` | | Refresh an access token for the TER. | 654 | | ``ter:token:revoke`` | ``token`` | | Revoke an access token for the TER. | 655 | | ``ter:transfer`` | ``username``
``extensionkey`` | | Transfer ownership of an extension key. | 656 | | ``ter:update`` | ``extensionkey`` | ``--composer``
``--issues``
``--repository``
``--manual``
``--paypal``
``--tags`` | Update extension meta information. | 657 | | ``ter:version`` | ``version``
``extensionkey`` | | Fetch details about an extension version. | 658 | | ``ter:versions`` | ``extensionkey`` | | Fetch details for all versions of an extension. | 659 | 660 | ### General options for all commands 661 | 662 | - ``-r, --raw`` Return result as raw object (e.g. json) - Only for commands, 663 | requesting the TER API 664 | - ``-h, --help`` Display help message 665 | - ``-q, --quiet`` Do not output any message 666 | - ``-v, --version`` Display the CLI applications' version 667 | - ``-n, --no-interaction`` Do not ask any interactive question 668 | - ``-v|vv|vvv, --verbose`` Increase the verbosity of messages: 1 for normal output, 2 669 | for more verbose output and 3 for debug 670 | - ``--ansi`` Force ANSI output 671 | - ``--no-ansi`` Disable ANSI output 672 | 673 | ## Author & License 674 | 675 | Created by Benni Mack and Oliver Bartsch. 676 | 677 | MIT License, see LICENSE 678 | 679 | [rest-api]: https://extensions.typo3.org/faq/rest-api/ 680 | [ter]: https://extensions.typo3.org 681 | [tailor-ext]: https://github.com/o-ba/tailor_ext 682 | [typo3-uploader-ter]: https://github.com/tomasnorre/typo3-upload-ter 683 | [typo3-uploader-ter-readme]: https://github.com/tomasnorre/typo3-upload-ter/blob/main/README.md 684 | -------------------------------------------------------------------------------- /bin/tailor: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | usePutenv(); 27 | $dotEnv->loadEnv($file); 28 | break; 29 | } 30 | } 31 | $application = new Application('Tailor - Your TYPO3 Extension Helper', '1.6.0'); 32 | $application->add(new Command\Auth\CreateTokenCommand('ter:token:create')); 33 | $application->add(new Command\Auth\RefreshTokenCommand('ter:token:refresh')); 34 | $application->add(new Command\Auth\RevokeTokenCommand('ter:token:revoke')); 35 | $application->add(new Command\Extension\CreateExtensionArtefactCommand('create-artefact')); 36 | $application->add(new Command\Extension\DeleteExtensionCommand('ter:delete')); 37 | $application->add(new Command\Extension\ExtensionDetailsCommand('ter:details')); 38 | $application->add(new Command\Extension\ExtensionVersionsCommand('ter:versions')); 39 | $application->add(new Command\Extension\FindExtensionsCommand('ter:find')); 40 | $application->add(new Command\Extension\RegisterExtensionCommand('ter:register')); 41 | $application->add(new Command\Extension\SetExtensionVersionCommand('set-version')); 42 | $application->add(new Command\Extension\TransferExtensionCommand('ter:transfer')); 43 | $application->add(new Command\Extension\UpdateExtensionCommand('ter:update')); 44 | $application->add(new Command\Extension\UploadExtensionVersionCommand('ter:publish')); 45 | $application->add(new Command\Extension\VersionDetailsCommand('ter:version')); 46 | $application->run(); 47 | }); 48 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typo3/tailor", 3 | "description": "A CLI tool to make TYPO3 extension handling easier", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Benni Mack", 8 | "email": "benni@typo3.org", 9 | "role": "Maintainer" 10 | }, 11 | { 12 | "name": "Oliver Bartsch", 13 | "email": "bo@cedev.de", 14 | "role": "Maintainer" 15 | } 16 | ], 17 | "config": { 18 | "sort-packages": true 19 | }, 20 | "require": { 21 | "php": "^7.2 || ^8.0", 22 | "ext-json": "*", 23 | "ext-zip": "*", 24 | "symfony/console": "^5.4 || ^6.4 || ^7.0", 25 | "symfony/dotenv": "^5.4 || ^6.4 || ^7.0", 26 | "symfony/http-client": "^5.4 || ^6.4 || ^7.0", 27 | "symfony/mime": "^5.4 || ^6.4 || ^7.0" 28 | }, 29 | "require-dev": { 30 | "phpunit/phpunit": "^8.5.36 || ^9.6.16", 31 | "typo3/coding-standards": "^0.6.1 || dev-main" 32 | }, 33 | "bin": [ 34 | "bin/tailor" 35 | ], 36 | "autoload": { 37 | "psr-4": { 38 | "TYPO3\\Tailor\\": "src/" 39 | } 40 | }, 41 | "autoload-dev": { 42 | "psr-4": { 43 | "TYPO3\\Tailor\\Tests\\": "tests/" 44 | } 45 | }, 46 | "scripts": { 47 | "tests:unit": [ 48 | "@php vendor/bin/phpunit --testsuite Unit" 49 | ], 50 | "cs": [ 51 | "@php vendor/bin/php-cs-fixer fix --dry-run --diff --config=vendor/typo3/coding-standards/templates/extension_php-cs-fixer.dist.php src/ tests/" 52 | ] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /conf/ExcludeFromPackaging.php: -------------------------------------------------------------------------------- 1 | [ 13 | '.build', 14 | '.ddev', 15 | '.git', 16 | '.github', 17 | '.gitlab', 18 | '.gitlab-ci', 19 | '.idea', 20 | '.phive', 21 | 'bin', 22 | 'build', 23 | 'public', 24 | 'tailor-version-artefact', 25 | 'tailor-version-upload', 26 | 'tests', 27 | 'tools', 28 | 'vendor', 29 | ], 30 | 'files' => [ 31 | 'CODE_OF_CONDUCT.md', 32 | 'DS_Store', 33 | 'Dockerfile', 34 | 'ExtensionBuilder.json', 35 | 'Makefile', 36 | 'bower.json', 37 | 'codeception.yml', 38 | 'composer.lock', 39 | 'crowdin.yaml', 40 | 'docker-compose.yml', 41 | 'dynamicReturnTypeMeta.json', 42 | 'editorconfig', 43 | 'env', 44 | 'eslintignore', 45 | 'eslintrc.json', 46 | 'gitattributes', 47 | 'gitignore', 48 | 'gitlab-ci.yml', 49 | 'gitmodules', 50 | 'gitreview', 51 | 'package-lock.json', 52 | 'package.json', 53 | 'phive.xml', 54 | 'php-cs-fixer.dist.php', 55 | 'php-cs-fixer.php', 56 | 'php_cs', 57 | 'php_cs.php', 58 | 'phpcs.xml', 59 | 'phpcs.xml.dist', 60 | 'phplint.yml', 61 | 'phpstan-baseline.neon', 62 | 'phpstan.neon', 63 | 'phpstan.neon.dist', 64 | 'phpstorm.meta.php', 65 | 'phpunit.xml', 66 | 'phpunit.xml.dist', 67 | 'prettierrc.json', 68 | 'rector.php', 69 | 'scrutinizer.yml', 70 | 'styleci.yml', 71 | 'stylelint.config.js', 72 | 'stylelintrc', 73 | 'travis.yml', 74 | 'tslint.yaml', 75 | 'tslint.yml', 76 | 'typoscript-lint.yaml', 77 | 'typoscript-lint.yml', 78 | 'typoscriptlint.yaml', 79 | 'typoscriptlint.yml', 80 | 'webpack.config.js', 81 | 'webpack.mix.js', 82 | 'yarn.lock', 83 | ], 84 | ]; 85 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | tests/Unit 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/Command/AbstractClientRequestCommand.php: -------------------------------------------------------------------------------- 1 | addOption('raw', 'r', InputOption::VALUE_OPTIONAL, 'Return result as raw object (e.g. json)', false); 49 | } 50 | 51 | protected function execute(InputInterface $input, OutputInterface $output): int 52 | { 53 | $this->input = $input; 54 | $io = new SymfonyStyle($input, $output); 55 | 56 | if ($this->confirmationRequired 57 | && !$io->askQuestion(new ConfirmationQuestion($this->getMessages()->getConfirmation())) 58 | ) { 59 | $io->writeln('Execution aborted.'); 60 | return 0; 61 | } 62 | 63 | $requestConfiguration = $this->getRequestConfiguration(); 64 | $requestConfiguration 65 | ->setRaw($input->getOption('raw') !== false) 66 | ->setDefaultAuthMethod($this->defaultAuthMethod); 67 | 68 | // RequestService returns a boolean for whether the request was successful or not. 69 | // Since we have to return an exit code, this must be negated and casted to return 70 | // 0 on success and 1 on failure. 71 | return (int)!(new RequestService( 72 | $requestConfiguration, 73 | new ConsoleWriter($io, $this->getMessages(), $this->resultFormat) 74 | ))->run(); 75 | } 76 | 77 | protected function setDefaultAuthMethod(int $defaultAuthMethod): self 78 | { 79 | $this->defaultAuthMethod = $defaultAuthMethod; 80 | return $this; 81 | } 82 | 83 | protected function setResultFormat(int $resultFormat): self 84 | { 85 | $this->resultFormat = $resultFormat; 86 | return $this; 87 | } 88 | 89 | protected function setConfirmationRequired(bool $confirmationRequired): self 90 | { 91 | $this->confirmationRequired = $confirmationRequired; 92 | return $this; 93 | } 94 | 95 | abstract protected function getRequestConfiguration(): RequestConfiguration; 96 | abstract protected function getMessages(): Messages; 97 | } 98 | -------------------------------------------------------------------------------- /src/Command/Auth/CreateTokenCommand.php: -------------------------------------------------------------------------------- 1 | setDescription('Request an access token for the TER') 33 | ->setDefaultAuthMethod(HttpClientFactory::BASIC_AUTH) 34 | ->addOption('name', '', InputOption::VALUE_OPTIONAL, 'Name of the access token') 35 | ->addOption('expires', '', InputOption::VALUE_OPTIONAL, 'Expiration in seconds') 36 | ->addOption('scope', '', InputOption::VALUE_OPTIONAL, 'Scopes as comma separated list', 'extension:read,extension:write') 37 | ->addOption('extensions', '', InputOption::VALUE_OPTIONAL, 'Extensions, the access token should have access to'); 38 | } 39 | 40 | protected function getRequestConfiguration(): RequestConfiguration 41 | { 42 | return new RequestConfiguration('POST', 'auth/token', $this->getQuery()); 43 | } 44 | 45 | protected function getMessages(): Messages 46 | { 47 | return new Messages( 48 | 'Creating an access token', 49 | 'Access token was successfully created.', 50 | 'Access token could not be created.' 51 | ); 52 | } 53 | 54 | protected function getQuery(): array 55 | { 56 | $options = $this->input->getOptions(); 57 | $query = []; 58 | 59 | foreach (self::ALLOWED_QUERY_OPTIONS as $name) { 60 | if ($options[$name] !== null) { 61 | $query[$name] = $options[$name]; 62 | } 63 | } 64 | 65 | return $query; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Command/Auth/RefreshTokenCommand.php: -------------------------------------------------------------------------------- 1 | setDescription('Refresh an access token for the TER') 31 | ->setDefaultAuthMethod(HttpClientFactory::BASIC_AUTH) 32 | ->addArgument('token', InputArgument::REQUIRED, 'The refresh token, recieved with the access token.'); 33 | } 34 | 35 | protected function getRequestConfiguration(): RequestConfiguration 36 | { 37 | return new RequestConfiguration( 38 | 'POST', 39 | 'auth/token/refresh', 40 | [], 41 | ['token' => $this->input->getArgument('token')], 42 | ['Content-Type' => 'application/x-www-form-urlencoded'] 43 | ); 44 | } 45 | 46 | protected function getMessages(): Messages 47 | { 48 | return new Messages( 49 | 'Refreshing an access token', 50 | 'Access token was successfully refreshed.', 51 | 'Access token could not be refreshed.' 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Command/Auth/RevokeTokenCommand.php: -------------------------------------------------------------------------------- 1 | setDescription('Revoke an access token for the TER') 32 | ->setDefaultAuthMethod(HttpClientFactory::BASIC_AUTH) 33 | ->setResultFormat(ConsoleFormatter::FORMAT_NONE) 34 | ->addArgument('token', InputArgument::REQUIRED, 'The access token to revoke.'); 35 | } 36 | 37 | protected function getRequestConfiguration(): RequestConfiguration 38 | { 39 | return new RequestConfiguration( 40 | 'POST', 41 | 'auth/token/revoke', 42 | [], 43 | ['token' => $this->input->getArgument('token')], 44 | ['Content-Type' => 'application/x-www-form-urlencoded'] 45 | ); 46 | } 47 | 48 | protected function getMessages(): Messages 49 | { 50 | return new Messages( 51 | 'Revoking an access token', 52 | 'Access token was successfully revoked.', 53 | 'Access token could not be revoked.' 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Command/Extension/CreateExtensionArtefactCommand.php: -------------------------------------------------------------------------------- 1 | setDescription('Create an artefact file (zip archive) of an extension'); 33 | 34 | $this->addArgument( 35 | 'version', 36 | InputArgument::REQUIRED, 37 | 'The version of the extension, e.g. 1.2.3' 38 | ); 39 | $this->addArgument( 40 | 'extensionkey', 41 | InputArgument::OPTIONAL, 42 | 'The extension key' 43 | ); 44 | $this->addOption( 45 | 'path', 46 | null, 47 | InputOption::VALUE_REQUIRED, 48 | 'Path to the extension folder' 49 | ); 50 | $this->addOption( 51 | 'artefact', 52 | null, 53 | InputOption::VALUE_REQUIRED, 54 | 'Path or URL to a zip file' 55 | ); 56 | } 57 | 58 | protected function execute(InputInterface $input, OutputInterface $output): int 59 | { 60 | $io = new SymfonyStyle($input, $output); 61 | 62 | $version = $input->getArgument('version'); 63 | $extensionKey = CommandHelper::getExtensionKeyFromInput($input); 64 | $path = $input->getOption('path'); 65 | $artefact = $input->getOption('artefact'); 66 | $transactionPath = rtrim(realpath(getcwd() ?: './'), '/') . '/tailor-version-artefact'; 67 | 68 | if (!(new Filesystem\Directory())->create($transactionPath)) { 69 | throw new \RuntimeException(sprintf('Directory could not be created: %s', $transactionPath)); 70 | } 71 | 72 | $versionService = new VersionService($version, $extensionKey, $transactionPath); 73 | 74 | if ($path !== null) { 75 | $versionService->createZipArchiveFromPath($path); 76 | } elseif ($artefact !== null) { 77 | $versionService->createZipArchiveFromArtefact($artefact); 78 | } else { 79 | // If neither `path` nor `artefact` are defined, we just 80 | // create the ZipArchive from the current directory. 81 | $versionService->createZipArchiveFromPath(getcwd() ?: './'); 82 | } 83 | 84 | $io->success(sprintf('Extension artefact successfully generated: %s', $versionService->getVersionFilePath())); 85 | 86 | return 0; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/Command/Extension/DeleteExtensionCommand.php: -------------------------------------------------------------------------------- 1 | setDescription('Delete an extension') 37 | ->setResultFormat(ConsoleFormatter::FORMAT_NONE) 38 | ->setConfirmationRequired(true) 39 | ->addArgument('extensionkey', InputArgument::OPTIONAL, 'The extension key'); 40 | } 41 | 42 | protected function execute(InputInterface $input, OutputInterface $output): int 43 | { 44 | $this->extensionKey = CommandHelper::getExtensionKeyFromInput($input); 45 | return parent::execute($input, $output); 46 | } 47 | 48 | protected function getRequestConfiguration(): RequestConfiguration 49 | { 50 | return new RequestConfiguration('DELETE', 'extension/' . $this->extensionKey); 51 | } 52 | 53 | protected function getMessages(): Messages 54 | { 55 | return new Messages( 56 | sprintf('Deleting extension %s', $this->extensionKey), 57 | sprintf('Extension %s successfully deleted.', $this->extensionKey), 58 | sprintf('Could not delete extension %s.', $this->extensionKey), 59 | sprintf('Are you sure you want to delete the extension %s?', $this->extensionKey) 60 | ); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Command/Extension/ExtensionDetailsCommand.php: -------------------------------------------------------------------------------- 1 | setDescription('Fetch details about an extension') 37 | ->setResultFormat(ConsoleFormatter::FORMAT_DETAIL) 38 | ->addArgument('extensionkey', InputArgument::OPTIONAL, 'The extension key'); 39 | } 40 | 41 | protected function execute(InputInterface $input, OutputInterface $output): int 42 | { 43 | $this->extensionKey = CommandHelper::getExtensionKeyFromInput($input); 44 | return parent::execute($input, $output); 45 | } 46 | 47 | protected function getRequestConfiguration(): RequestConfiguration 48 | { 49 | return new RequestConfiguration('GET', 'extension/' . $this->extensionKey); 50 | } 51 | 52 | protected function getMessages(): Messages 53 | { 54 | return new Messages( 55 | sprintf('Fetching details for extension %s', $this->extensionKey), 56 | sprintf('Successfully fetched extensions details for extension %s.', $this->extensionKey), 57 | sprintf('Extension details for extension %s could not be fetched.', $this->extensionKey) 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Command/Extension/ExtensionVersionsCommand.php: -------------------------------------------------------------------------------- 1 | setDescription('Fetch details for all versions of the extension') 37 | ->setResultFormat(ConsoleFormatter::FORMAT_DETAIL) 38 | ->addArgument('extensionkey', InputArgument::OPTIONAL, 'The extension key'); 39 | } 40 | 41 | protected function execute(InputInterface $input, OutputInterface $output): int 42 | { 43 | $this->extensionKey = CommandHelper::getExtensionKeyFromInput($input); 44 | // @todo the response format needs to be adjusted! 45 | return parent::execute($input, $output); 46 | } 47 | 48 | protected function getRequestConfiguration(): RequestConfiguration 49 | { 50 | return new RequestConfiguration('GET', 'extension/' . $this->extensionKey . '/versions'); 51 | } 52 | 53 | protected function getMessages(): Messages 54 | { 55 | return new Messages( 56 | sprintf('Fetching details for all versions of extension %s', $this->extensionKey), 57 | sprintf('Successfully fetched details for all versions of extension %s.', $this->extensionKey), 58 | sprintf('Could not fetch details for all version of extension %s.', $this->extensionKey) 59 | ); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Command/Extension/FindExtensionsCommand.php: -------------------------------------------------------------------------------- 1 | 'page', 'per-page' => 'per_page']; 27 | private const FILTER_OPTION_MAPPING = ['author' => 'username', 'typo3-version' => 'typo3_version']; 28 | 29 | protected function configure(): void 30 | { 31 | parent::configure(); 32 | $this 33 | ->setDescription('Fetch a list of extensions from TER') 34 | ->setResultFormat(ConsoleFormatter::FORMAT_TABLE) 35 | ->addOption('page', '', InputOption::VALUE_OPTIONAL, 'Page number for paginated result') 36 | ->addOption('per-page', '', InputOption::VALUE_OPTIONAL, 'Per page limit for paginated result') 37 | ->addOption('author', '', InputOption::VALUE_OPTIONAL, 'Filter by a specific author. Use the TYPO3 username.') 38 | ->addOption('typo3-version', '', InputOption::VALUE_OPTIONAL, 'Only list extensions compatible with a specific major TYPO3 version'); 39 | } 40 | 41 | protected function getRequestConfiguration(): RequestConfiguration 42 | { 43 | return new RequestConfiguration('GET', 'extension', $this->getQuery()); 44 | } 45 | 46 | protected function getMessages(): Messages 47 | { 48 | return new Messages( 49 | 'Fetching registered remote extensions', 50 | 'Successfully fetched remote extensions.', 51 | 'Could not fetch remote extensions.' 52 | ); 53 | } 54 | 55 | protected function getQuery(): array 56 | { 57 | $options = $this->input->getOptions(); 58 | $query = []; 59 | 60 | foreach (self::PAGINATION_OPTION_MAPPING as $optionName => $queryName) { 61 | if ($options[$optionName] !== null) { 62 | $query[$queryName] = $options[$optionName]; 63 | } 64 | } 65 | 66 | foreach (self::FILTER_OPTION_MAPPING as $optionName => $queryName) { 67 | if ($options[$optionName] !== null) { 68 | $query['filter'][$queryName] = $options[$optionName]; 69 | } 70 | } 71 | 72 | return $query; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Command/Extension/RegisterExtensionCommand.php: -------------------------------------------------------------------------------- 1 | setDescription('Register a new extension key in TER') 36 | ->addArgument('extensionkey', InputArgument::OPTIONAL, 'Define an extension key'); 37 | } 38 | 39 | protected function execute(InputInterface $input, OutputInterface $output): int 40 | { 41 | $this->extensionKey = CommandHelper::getExtensionKeyFromInput($input); 42 | return parent::execute($input, $output); 43 | } 44 | 45 | protected function getRequestConfiguration(): RequestConfiguration 46 | { 47 | return new RequestConfiguration('POST', 'extension/' . $this->extensionKey); 48 | } 49 | 50 | protected function getMessages(): Messages 51 | { 52 | return new Messages( 53 | sprintf('Registering the extension key %s', $this->extensionKey), 54 | sprintf('Successfully registered extension key %s.', $this->extensionKey), 55 | sprintf('Could not register extension key %s.', $this->extensionKey) 56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Command/Extension/SetExtensionVersionCommand.php: -------------------------------------------------------------------------------- 1 | \s["\']((?:[0-9]+)\.[0-9]+\.[0-9]+\s*)["\']'; 32 | 33 | // Documentation/guides.xml 34 | private const DOCUMENTATION_RELEASE_PATTERN = 'release="([0-9]+\.[0-9]+\.[0-9]+)"'; 35 | private const DOCUMENTATION_VERSION_PATTERN = 'version="([0-9]+\.[0-9]+)"'; 36 | 37 | // Documentation/Settings.cfg 38 | private const DOCUMENTATION_RELEASE_LEGACY_PATTERN = 'release\s*=\s*([0-9]+\.[0-9]+\.[0-9]+)'; 39 | private const DOCUMENTATION_VERSION_LEGACY_PATTERN = 'version\s*=\s*([0-9]+\.[0-9]+)'; 40 | 41 | protected function configure(): void 42 | { 43 | parent::configure(); 44 | $this 45 | ->setDescription('Update the extensions ext_emconf.php version to a specific version. Useful in CI environments') 46 | ->addArgument('version', InputArgument::REQUIRED, 'The version to publish, e.g. 1.2.3. Must have three digits.') 47 | ->addOption('path', '', InputOption::VALUE_OPTIONAL, 'Path to the extension folder', getcwd() ?: './') 48 | ->addOption('no-docs', '', InputOption::VALUE_OPTIONAL, 'Disable version update in documentation settings', false); 49 | } 50 | 51 | protected function execute(InputInterface $input, OutputInterface $output): int 52 | { 53 | $io = new SymfonyStyle($input, $output); 54 | $version = (string)$input->getArgument('version'); 55 | 56 | if (!(new VersionValidator())->isValid($version)) { 57 | $io->error(sprintf('The given version "%s" must contain three digits in the format "1.2.3".', $version)); 58 | return 1; 59 | } 60 | 61 | $path = realpath($input->getOption('path')); 62 | if (!$path) { 63 | $io->error(sprintf('Given path %s does not exist.', $path)); 64 | return 1; 65 | } 66 | 67 | $emConfFile = rtrim($path, '/') . '/ext_emconf.php'; 68 | if (!file_exists($emConfFile)) { 69 | $io->error(sprintf('No \'ext_emconf.php\' found in the given path %s.', $path)); 70 | return 1; 71 | } 72 | 73 | $versionReplacer = new VersionReplacer($version); 74 | 75 | try { 76 | $versionReplacer->setVersion($emConfFile, self::EMCONF_PATTERN); 77 | } catch (\InvalidArgumentException $e) { 78 | $io->error(sprintf('An error occurred while setting the ext_emconf.php version to %s.', $version)); 79 | return 1; 80 | } 81 | 82 | if ($input->getOption('no-docs') === null 83 | || (bool)$input->getOption('no-docs') === true 84 | || Variables::has('TYPO3_DISABLE_DOCS_VERSION_UPDATE') 85 | ) { 86 | return 0; 87 | } 88 | 89 | $documentationVersionReplaced = false; 90 | $documentationSettingsFiles = [ 91 | rtrim($path, '/') . '/Documentation/guides.xml' => [ 92 | self::DOCUMENTATION_RELEASE_PATTERN, 93 | self::DOCUMENTATION_VERSION_PATTERN, 94 | ], 95 | rtrim($path, '/') . '/Documentation/Settings.cfg' => [ 96 | self::DOCUMENTATION_RELEASE_LEGACY_PATTERN, 97 | self::DOCUMENTATION_VERSION_LEGACY_PATTERN, 98 | ], 99 | ]; 100 | 101 | foreach ($documentationSettingsFiles as $documentationSettingsFile => [$documentationReleasePattern, $documentationVersionPattern]) { 102 | if (!file_exists($documentationSettingsFile)) { 103 | continue; 104 | } 105 | 106 | try { 107 | $versionReplacer->setVersion($documentationSettingsFile, $documentationReleasePattern); 108 | } catch (\InvalidArgumentException $e) { 109 | $io->error(sprintf('An error occurred while updating the release number in %s', $documentationSettingsFile)); 110 | return 1; 111 | } 112 | 113 | try { 114 | $versionReplacer->setVersion($documentationSettingsFile, $documentationVersionPattern, 2); 115 | } catch (\InvalidArgumentException $e) { 116 | $io->error(sprintf('An error occurred while updating the version number in %s', $documentationSettingsFile)); 117 | return 1; 118 | } 119 | 120 | $documentationVersionReplaced = true; 121 | } 122 | 123 | if (!$documentationVersionReplaced) { 124 | $io->note( 125 | 'Documentation version update is enabled but was not performed because the files ' 126 | . implode(' and ', array_keys($documentationSettingsFiles)) . ' do not exist. ' 127 | . 'To disable this operation use the \'--no-docs\' option or set the ' 128 | . '\'TYPO3_DISABLE_DOCS_VERSION_UPDATE\' environment variable.' 129 | ); 130 | } 131 | 132 | return 0; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/Command/Extension/TransferExtensionCommand.php: -------------------------------------------------------------------------------- 1 | setDescription('Transfer ownership of an extension key') 39 | ->setConfirmationRequired(true) 40 | ->addArgument('username', InputArgument::REQUIRED, 'The TYPO3 username the extension should be transfered to') 41 | ->addArgument('extensionkey', InputArgument::OPTIONAL, 'The extension key'); 42 | } 43 | 44 | protected function execute(InputInterface $input, OutputInterface $output): int 45 | { 46 | $this->username = $input->getArgument('username'); 47 | $this->extensionKey = CommandHelper::getExtensionKeyFromInput($input); 48 | return parent::execute($input, $output); 49 | } 50 | 51 | protected function getRequestConfiguration(): RequestConfiguration 52 | { 53 | return new RequestConfiguration('POST', 'extension/' . $this->extensionKey . '/transfer/' . $this->username); 54 | } 55 | 56 | protected function getMessages(): Messages 57 | { 58 | $variables = [$this->extensionKey, $this->username]; 59 | 60 | return new Messages( 61 | sprintf('Transferring extension key %s to %s', ...$variables), 62 | sprintf('Extension key %s successfully transferred to %s.', ...$variables), 63 | sprintf('Could not transfer extension key %s to %s.', ...$variables), 64 | sprintf('Are you sure you want to transfer the extension key %s to %s?', ...$variables) 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Command/Extension/UpdateExtensionCommand.php: -------------------------------------------------------------------------------- 1 | 'composer_name', 32 | 'issues' => 'forge_link', 33 | 'repository' => 'repository_url', 34 | 'manual' => 'external_manual', 35 | 'paypal' => 'paypal_url', 36 | 'tags' => 'tags', 37 | ]; 38 | 39 | /** @var string */ 40 | protected $extensionKey; 41 | 42 | protected function configure(): void 43 | { 44 | parent::configure(); 45 | $this 46 | ->setDescription('Update extension meta information') 47 | ->setResultFormat(ConsoleFormatter::FORMAT_DETAIL) 48 | ->addArgument('extensionkey', InputArgument::OPTIONAL, 'The extension key') 49 | ->addOption('composer', '', InputOption::VALUE_OPTIONAL, 'The extensions composer name') 50 | ->addOption('issues', '', InputOption::VALUE_OPTIONAL, 'Link to the issue tracker') 51 | ->addOption('repository', '', InputOption::VALUE_OPTIONAL, 'Link to the repository') 52 | ->addOption('manual', '', InputOption::VALUE_OPTIONAL, 'Link to the external manual') 53 | ->addOption('paypal', '', InputOption::VALUE_OPTIONAL, 'Link to sponsoring page (paypal)') 54 | ->addOption('tags', '', InputOption::VALUE_OPTIONAL, 'Comma-separated list of tags'); 55 | } 56 | 57 | protected function execute(InputInterface $input, OutputInterface $output): int 58 | { 59 | $this->extensionKey = CommandHelper::getExtensionKeyFromInput($input); 60 | return parent::execute($input, $output); 61 | } 62 | 63 | protected function getRequestConfiguration(): RequestConfiguration 64 | { 65 | return new RequestConfiguration( 66 | 'PUT', 67 | 'extension/' . $this->extensionKey, 68 | [], 69 | $this->getFormData(), 70 | ['Content-Type' => 'application/x-www-form-urlencoded'] 71 | ); 72 | } 73 | 74 | protected function getMessages(): Messages 75 | { 76 | return new Messages( 77 | sprintf('Updating meta information of extension %s', $this->extensionKey), 78 | sprintf('Meta information of extension %s successfully updated.', $this->extensionKey), 79 | sprintf('Could not update meta information of extension %s.', $this->extensionKey) 80 | ); 81 | } 82 | 83 | private function getFormData(): array 84 | { 85 | $options = $this->input->getOptions(); 86 | $formData = []; 87 | 88 | foreach (self::OPTION_TO_FORM_DATA_MAPPING as $optionName => $formName) { 89 | if ($options[$optionName] !== null) { 90 | $formData[$formName] = $options[$optionName]; 91 | } 92 | } 93 | 94 | return $formData; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Command/Extension/UploadExtensionVersionCommand.php: -------------------------------------------------------------------------------- 1 | setDescription('Publishes a new version of an extension to TER') 50 | ->setResultFormat(ConsoleFormatter::FORMAT_DETAIL) 51 | ->addArgument('version', InputArgument::REQUIRED, 'The version to publish, e.g. 1.2.3') 52 | ->addArgument('extensionkey', InputArgument::OPTIONAL, 'The extension key') 53 | ->addOption('path', '', InputOption::VALUE_OPTIONAL, 'Path to the extension folder') 54 | ->addOption('artefact', '', InputOption::VALUE_OPTIONAL, 'Path or URL to a zip file') 55 | ->addOption('comment', '', InputOption::VALUE_OPTIONAL, 'Upload comment of the new version (e.g. release notes)'); 56 | } 57 | 58 | protected function execute(InputInterface $input, OutputInterface $output): int 59 | { 60 | $this->version = $input->getArgument('version'); 61 | $this->extensionKey = CommandHelper::getExtensionKeyFromInput($input); 62 | $this->transactionPath = rtrim(realpath(getcwd() ?: './'), '/') . '/tailor-version-upload'; 63 | 64 | if (!(new Filesystem\Directory())->create($this->transactionPath)) { 65 | throw new \RuntimeException(sprintf('Directory could not be created.')); 66 | } 67 | 68 | return parent::execute($input, $output); 69 | } 70 | 71 | protected function getRequestConfiguration(): RequestConfiguration 72 | { 73 | $formDataPart = $this->getFormDataPart($this->input->getOptions()); 74 | 75 | return new RequestConfiguration( 76 | 'POST', 77 | 'extension/' . $this->extensionKey . '/' . $this->version, 78 | [], 79 | [], 80 | [], 81 | false, 82 | HttpClientFactory::ALL_AUTH, 83 | $formDataPart 84 | ); 85 | } 86 | 87 | protected function getMessages(): Messages 88 | { 89 | $variables = [$this->version, $this->extensionKey]; 90 | 91 | return new Messages( 92 | sprintf('Publishing version %s of extension %s', ...$variables), 93 | sprintf('Version %s of extension %s successfully published.', ...$variables), 94 | sprintf('Could not publish version %s of extension %s.', ...$variables) 95 | ); 96 | } 97 | 98 | /** 99 | * Create FormDataPart from given options. 100 | * This also creates a proper DataPart (containing the version as ZipArchive) 101 | * from either a given path or an existing ZipArchive (local or remote). 102 | * 103 | * @param array $options 104 | * @return FormDataPart 105 | */ 106 | protected function getFormDataPart(array $options): FormDataPart 107 | { 108 | if ($options['comment'] === null) { 109 | // The REST API requires a description to be set (just like the GUI does). 110 | // For now we just generate a description from the given version if non is given. 111 | $options['comment'] = 'Updated extension to ' . $this->version; 112 | } 113 | 114 | $versionService = new VersionService($this->version, $this->extensionKey, $this->transactionPath); 115 | 116 | if ($options['path'] !== null) { 117 | $versionService->createZipArchiveFromPath((string)$options['path']); 118 | } elseif ($options['artefact'] !== null) { 119 | $versionService->createZipArchiveFromArtefact(trim((string)$options['artefact'])); 120 | } else { 121 | // If neither `path` nor `artefact` is defined, we just 122 | // create the ZipArchive from the current directory. 123 | $versionService->createZipArchiveFromPath(getcwd() ?: './'); 124 | } 125 | 126 | return new FormDataPart([ 127 | 'description' => (string)$options['comment'], 128 | 'gplCompliant' => '1', 129 | 'file' => DataPart::fromPath($versionService->getVersionFilePath()), 130 | ]); 131 | } 132 | 133 | /** 134 | * Clean the transaction directory and all its content. 135 | * This includes the final ZipArchive, but not the given 136 | * path from which the ZipArchive was created. 137 | * 138 | * Note: Using __destruct(), we ensure the transaction 139 | * directory will be removed in any case. Even if an 140 | * exception is thrown. 141 | */ 142 | public function __destruct() 143 | { 144 | if (!(bool)($this->transactionPath ?? false)) { 145 | return; 146 | } 147 | 148 | (new Filesystem\Directory())->remove($this->transactionPath); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/Command/Extension/VersionDetailsCommand.php: -------------------------------------------------------------------------------- 1 | setDescription('Fetch details about an extension version') 40 | ->setResultFormat(ConsoleFormatter::FORMAT_DETAIL) 41 | ->addArgument('version', InputArgument::REQUIRED, 'The version to publish, e.g. 1.2.3') 42 | ->addArgument('extensionkey', InputArgument::OPTIONAL, 'The extension key'); 43 | } 44 | 45 | protected function execute(InputInterface $input, OutputInterface $output): int 46 | { 47 | $this->version = $input->getArgument('version'); 48 | $this->extensionKey = CommandHelper::getExtensionKeyFromInput($input); 49 | return parent::execute($input, $output); 50 | } 51 | 52 | protected function getRequestConfiguration(): RequestConfiguration 53 | { 54 | return new RequestConfiguration('GET', 'extension/' . $this->extensionKey . '/' . $this->version); 55 | } 56 | 57 | protected function getMessages(): Messages 58 | { 59 | $variables = [$this->version, $this->extensionKey]; 60 | 61 | return new Messages( 62 | sprintf('Fetching details about version %s of extension %s', ...$variables), 63 | sprintf('Successfully fetched details for version %s of extension %s.', ...$variables), 64 | sprintf('Could not fetch details for version %s of extension %s.', ...$variables) 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Dto/Messages.php: -------------------------------------------------------------------------------- 1 | title = $title ?: 'Starting the command'; 39 | $this->success = $success ?: 'Command execution was successful.'; 40 | $this->failure = $failure ?: 'Command execution has failed.'; 41 | $this->confirmation = $confirmation ?: 'Are you sure you want to continue?'; 42 | } 43 | 44 | public function getTitle(): string 45 | { 46 | return $this->title; 47 | } 48 | 49 | public function getSuccess(): string 50 | { 51 | return $this->success; 52 | } 53 | 54 | public function getFailure(): string 55 | { 56 | return $this->failure; 57 | } 58 | 59 | public function getConfirmation(): string 60 | { 61 | return $this->confirmation; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Dto/RequestConfiguration.php: -------------------------------------------------------------------------------- 1 | method = $method; 65 | $this->endpoint = $endpoint; 66 | $this->query = $query; 67 | $this->body = $body; 68 | $this->headers = $headers; 69 | $this->raw = $raw; 70 | $this->defaultAuthMethod = $defaultAuthMethod; 71 | $this->formData = $formData; 72 | } 73 | 74 | public function getMethod(): string 75 | { 76 | return $this->method; 77 | } 78 | 79 | public function getEndpoint(): string 80 | { 81 | return $this->endpoint; 82 | } 83 | 84 | public function getQuery(): iterable 85 | { 86 | return $this->query; 87 | } 88 | 89 | public function getBody(): iterable 90 | { 91 | return $this->body; 92 | } 93 | 94 | public function getFormData(): ?FormDataPart 95 | { 96 | return $this->formData; 97 | } 98 | 99 | public function getHeaders(): iterable 100 | { 101 | $headers = $this->headers; 102 | if ($this->formData) { 103 | foreach ($this->formData->getPreparedHeaders()->all() as $key => $value) { 104 | $headers[$key] = $value->getBodyAsString(); 105 | } 106 | } 107 | return $headers; 108 | } 109 | 110 | public function setRaw(bool $raw): self 111 | { 112 | $this->raw = $raw; 113 | return $this; 114 | } 115 | 116 | public function isRaw(): bool 117 | { 118 | return $this->raw; 119 | } 120 | 121 | public function setDefaultAuthMethod(int $defaultAuthMethod): self 122 | { 123 | $this->defaultAuthMethod = $defaultAuthMethod; 124 | return $this; 125 | } 126 | 127 | public function getDefaultAuthMethod(): int 128 | { 129 | return $this->defaultAuthMethod; 130 | } 131 | 132 | public function isSuccessful(int $statusCode): bool 133 | { 134 | return in_array($statusCode, $this->successCodes, true); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/Environment/Variables.php: -------------------------------------------------------------------------------- 1 | composerSchema = json_decode($content, true); 36 | if (!$this->composerSchema || $this->composerSchema === []) { 37 | throw new InvalidComposerJsonException('The composer.json found is invalid!', 1610442954); 38 | } 39 | } 40 | 41 | public function getExtensionKey(): string 42 | { 43 | return $this->composerSchema['extra']['typo3/cms']['extension-key'] ?? ''; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Filesystem/Directory.php: -------------------------------------------------------------------------------- 1 | isDir()) { 48 | rmdir($file->getPathname()); 49 | continue; 50 | } 51 | unlink($file->getPathname()); 52 | } 53 | 54 | return rmdir($directory); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Filesystem/VersionReplacer.php: -------------------------------------------------------------------------------- 1 | versionParts = explode('.', $newVersion); 32 | } 33 | 34 | public function setVersion(string $filePath, string $pattern, int $versionPartsToUse = 3): void 35 | { 36 | $newVersion = ''; 37 | for ($i = 0; $i < $versionPartsToUse; $i++) { 38 | $newVersion .= $this->versionParts[$i] . '.'; 39 | } 40 | $newVersion = rtrim($newVersion, '.'); 41 | 42 | $fileContents = @file_get_contents($filePath); 43 | if ($fileContents === false) { 44 | throw new \InvalidArgumentException('The file ' . $filePath . ' could not be opened', 1605741968); 45 | } 46 | $updatedFileContents = preg_replace_callback('/' . $pattern . '/u', static function ($matches) use ($newVersion) { 47 | return str_replace($matches[1], $newVersion, $matches[0]); 48 | }, $fileContents); 49 | file_put_contents($filePath, $updatedFileContents); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Formatter/ConsoleFormatter.php: -------------------------------------------------------------------------------- 1 | formatType = $formatType; 37 | $this->formattedParts = new OutputParts(); 38 | } 39 | 40 | public function format(array $content): OutputParts 41 | { 42 | switch ($this->formatType) { 43 | case self::FORMAT_NONE: 44 | break; 45 | case self::FORMAT_DETAIL: 46 | $this->formatDetailsResult($content); 47 | break; 48 | case self::FORMAT_TABLE: 49 | $this->formatTable($content); 50 | break; 51 | case self::FORMAT_KEY_VALUE: 52 | default: 53 | $this->formatKeyValue($content); 54 | break; 55 | } 56 | 57 | return $this->formattedParts; 58 | } 59 | 60 | protected function formatKeyValue(array $content): void 61 | { 62 | foreach ($content as $key => $value) { 63 | if (is_array($value)) { 64 | // Not a key value pair 65 | continue; 66 | } 67 | if (!is_string($key)) { 68 | // Just output the value for a non-string key 69 | $this->formattedParts->addPart(new OutputPart([(string)$value])); 70 | continue; 71 | } 72 | $this->formattedParts->addPart( 73 | new OutputPart([sprintf('%s: %s', '' . $this->normalizeFieldName($key) . '', (string)$value)]) 74 | ); 75 | } 76 | } 77 | 78 | protected function formatDetailsResult(array $content): void 79 | { 80 | foreach ($content as $key => $value) { 81 | if (is_array($value)) { 82 | if ($value === []) { 83 | continue; 84 | } 85 | if (is_string($key)) { 86 | $this->formattedParts->addPart(new OutputPart([PHP_EOL . $this->normalizeFieldName($key)])); 87 | } 88 | $this->formatDetailsResult($value); 89 | } 90 | if (is_array($value) || (string)$value === 'Array' || (is_string($value) && $value === '')) { 91 | continue; 92 | } 93 | if (!is_string($key)) { 94 | $this->formattedParts->addPart(new OutputPart([(string)$value])); 95 | continue; 96 | } 97 | $this->formattedParts->addPart( 98 | new OutputPart([sprintf('%s: %s', '' . $this->normalizeFieldName($key) . '', (string)$value)]) 99 | ); 100 | } 101 | } 102 | 103 | protected function formatTable(array $content): void 104 | { 105 | $extensions = []; 106 | foreach ($content['extensions'] as $extensionData) { 107 | $extensions[$extensionData['key']] = [ 108 | $extensionData['key'], 109 | $extensionData['current_version']['title'] ?? '-', 110 | $extensionData['current_version']['number'] ?? '-', 111 | isset($extensionData['current_version']['upload_date']) ? date('d.m.Y', $extensionData['current_version']['upload_date']) : '-', 112 | $extensionData['meta']['composer_name'] ?? '-', 113 | ]; 114 | } 115 | ksort($extensions); 116 | $this->formattedParts->addPart( 117 | new OutputPart( 118 | [ 119 | ['Extension Key', 'Title', 'Latest Version', 'Last Updated on', 'Composer Name'], 120 | $extensions, 121 | ], 122 | OutputPart::OUTPUT_TABLE 123 | ) 124 | ); 125 | $this->formattedParts->addPart( 126 | new OutputPart([($extensions === [] ? 'No extensions found for options ' : '') . $this->getPaginationOptions($content)]) 127 | ); 128 | } 129 | 130 | protected function normalizeFieldName(string $fieldName): string 131 | { 132 | return ucfirst(implode(' ', explode('_', $fieldName))); 133 | } 134 | 135 | protected function getPaginationOptions(array $content): string 136 | { 137 | return sprintf( 138 | 'Page: %d, Per page: %d, Filter: %s', 139 | $content['page'], 140 | $content['per_page'], 141 | $this->getFilterString($content['filter']) 142 | ); 143 | } 144 | 145 | protected function getFilterString(array $filter): string 146 | { 147 | if ($filter === []) { 148 | return '-'; 149 | } 150 | 151 | $content = ''; 152 | 153 | if (!empty($filter['username'])) { 154 | $content .= $filter['username'] . ' (Author)'; 155 | } 156 | 157 | if (!empty($filter['typo3_version'])) { 158 | $content .= ', ' . $filter['typo3_version'] . ' (TYPO3 version)'; 159 | } 160 | 161 | return trim($content, ', '); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/Helper/CommandHelper.php: -------------------------------------------------------------------------------- 1 | hasArgument('extensionkey') 28 | && ($key = ($input->getArgument('extensionkey') ?? '')) !== '' 29 | ) { 30 | $extensionKey = $key; 31 | } elseif (Variables::has('TYPO3_EXTENSION_KEY')) { 32 | $extensionKey = Variables::get('TYPO3_EXTENSION_KEY'); 33 | } elseif (($extensionKeyFromComposer = (new ComposerReader())->getExtensionKey()) !== '') { 34 | $extensionKey = $extensionKeyFromComposer; 35 | } else { 36 | throw new ExtensionKeyMissingException( 37 | 'The extension key must either be set as argument, as environment variable or in the composer.json.', 38 | 1605706548 39 | ); 40 | } 41 | 42 | return $extensionKey; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/HttpClientFactory.php: -------------------------------------------------------------------------------- 1 | getDefaultAuthMethod(); 37 | $options = [ 38 | 'base_uri' => self::getBaseUri(), 39 | 'headers' => array_replace_recursive([ 40 | 'Accept' => 'application/json', 41 | 'User-Agent' => 'Tailor - Your TYPO3 Extension Helper', 42 | ], $requestConfiguration->getHeaders()), 43 | // REST API does not perform redirects 44 | 'max_redirects' => 0, 45 | ]; 46 | if ($requestConfiguration->getQuery() !== []) { 47 | $options['query'] = $requestConfiguration->getQuery(); 48 | } 49 | if ($requestConfiguration->getBody() !== []) { 50 | $options['body'] = $requestConfiguration->getBody(); 51 | } elseif ($requestConfiguration->getFormData()) { 52 | $options['body'] = $requestConfiguration->getFormData()->bodyToString(); 53 | } 54 | if (($defaultAuthMethod === self::BEARER_AUTH || $defaultAuthMethod === self::ALL_AUTH) 55 | && Variables::has('TYPO3_API_TOKEN') 56 | ) { 57 | $options['auth_bearer'] = Variables::get('TYPO3_API_TOKEN'); 58 | } elseif (($defaultAuthMethod === self::BASIC_AUTH || $defaultAuthMethod === self::ALL_AUTH) 59 | && (Variables::has('TYPO3_API_USERNAME') && Variables::has('TYPO3_API_PASSWORD')) 60 | ) { 61 | $options['auth_basic'] = [Variables::get('TYPO3_API_USERNAME'), Variables::get('TYPO3_API_PASSWORD')]; 62 | } else { 63 | // Since currently only requests to access restricted endpoints are implemented, 64 | // we can throw an exception if the request lacks authentication credentials. 65 | throw new \InvalidArgumentException('No authentication credentials are defined.', 1606995339); 66 | } 67 | return HttpClient::create($options); 68 | } 69 | 70 | protected static function getBaseUri(): string 71 | { 72 | $remoteBaseUri = Variables::get('TYPO3_REMOTE_BASE_URI') ?: self::DEFAULT_BASE_URI; 73 | $apiVersion = Variables::get('TYPO3_API_VERSION') ?: self::DEFAULT_API_VERSION; 74 | 75 | return trim($remoteBaseUri, '/') . self::API_ENTRY_POINT . trim($apiVersion, '/') . '/'; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/Output/OutputPart.php: -------------------------------------------------------------------------------- 1 | values = $values; 32 | $this->style = $style; 33 | } 34 | 35 | public function getValues(): array 36 | { 37 | return $this->values; 38 | } 39 | 40 | public function getOutputStyle(): string 41 | { 42 | return $this->style; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Output/OutputPartInterface.php: -------------------------------------------------------------------------------- 1 | parts[] = $part; 26 | } 27 | 28 | public function getParts(): array 29 | { 30 | return $this->parts; 31 | } 32 | 33 | public function count(): int 34 | { 35 | return count($this->parts); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Service/RequestService.php: -------------------------------------------------------------------------------- 1 | client = HttpClientFactory::create($requestConfiguration); 41 | $this->requestConfiguration = $requestConfiguration; 42 | $this->consoleWriter = $consoleWriter; 43 | $this->isRaw = $this->requestConfiguration->isRaw(); 44 | } 45 | 46 | /** 47 | * Run the request by the given request configuration and 48 | * output the formatted result using the ConsoleWriter. 49 | * 50 | * @return bool 51 | */ 52 | public function run(): bool 53 | { 54 | if (!$this->isRaw) { 55 | $this->consoleWriter->writeTitle(); 56 | } 57 | 58 | try { 59 | $response = $this->client->request($this->requestConfiguration->getMethod(), $this->requestConfiguration->getEndpoint()); 60 | $content = $response->getContent(false); 61 | $status = $response->getStatusCode(); 62 | 63 | if ($this->isRaw) { 64 | // If no content is provided in the response, usually on 200 65 | // responses for requests which delete the remote resource, 66 | // we ensure to return at least the status code on the CLI. 67 | $this->consoleWriter->write($content ?: json_encode(['status' => $status])); 68 | return true; 69 | } 70 | 71 | $content = (array)(json_decode($content, true) ?? []); 72 | 73 | if ($this->requestConfiguration->isSuccessful($status)) { 74 | $this->consoleWriter->writeSuccess(); 75 | $this->consoleWriter->writeFormattedResult($content); 76 | } else { 77 | $this->consoleWriter->writeFailure( 78 | (string)($content['error_description'] ?? $content['message'] ?? 'Unknown (Status ' . $status . ')') 79 | ); 80 | return false; 81 | } 82 | } catch (ExceptionInterface|\InvalidArgumentException $e) { 83 | $this->consoleWriter->error('An error occurred: ' . $e->getMessage()); 84 | return false; 85 | } 86 | 87 | return true; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/Service/VersionService.php: -------------------------------------------------------------------------------- 1 | version = $version; 44 | $this->extension = $extension; 45 | $this->transactionPath = $transactionPath; 46 | $this->excludeConfiguration = $this->getExcludeConfiguration(); 47 | } 48 | 49 | /** 50 | * Create the final ZipArchive for the given directory after validation 51 | * of the given files (e.g. ext_emconf.php). 52 | * 53 | * @param string $path Path to the directory, whose content should be added to the ZipArchive 54 | * @return string The full path to the ZipArchive 55 | */ 56 | public function createZipArchiveFromPath(string $path): string 57 | { 58 | $fullPath = realpath($path); 59 | 60 | if (!$fullPath) { 61 | throw new FormDataProcessingException('Path is not valid.', 1605562741); 62 | } 63 | 64 | $zipArchive = new \ZipArchive(); 65 | $zipArchive->open($this->getVersionFilename(), \ZipArchive::CREATE | \ZipArchive::OVERWRITE); 66 | 67 | $emConfValidationErrors = [EmConfValidationError::NOT_FOUND]; 68 | 69 | $iterator = new \RecursiveDirectoryIterator($fullPath, \FilesystemIterator::SKIP_DOTS); 70 | $files = new \RecursiveIteratorIterator( 71 | new \RecursiveCallbackFilterIterator($iterator, function ($current) use ($fullPath) { 72 | // @todo Find a more performant way for filtering 73 | 74 | $filepath = $current->getRealPath(); 75 | $filename = $current->getFilename(); 76 | 77 | if (!$filepath || !$filename || !($path = substr($filepath, strlen($fullPath) + 1))) { 78 | return false; 79 | } 80 | 81 | if ($current->isDir()) { 82 | // if $current is a directory, check for excluded directories 83 | foreach ($this->excludeConfiguration['directories'] as $excludeDirectory) { 84 | if (preg_match('/^' . $excludeDirectory . '/i', $path)) { 85 | return false; 86 | } 87 | } 88 | } 89 | 90 | if ($current->isFile()) { 91 | // if $current is a file, check for excluded files 92 | foreach ($this->excludeConfiguration['files'] as $excludeFile) { 93 | if (preg_match('/' . $excludeFile . '$/i', $filename)) { 94 | return false; 95 | } 96 | } 97 | } 98 | 99 | return true; 100 | }), 101 | \RecursiveIteratorIterator::LEAVES_ONLY 102 | ); 103 | 104 | foreach ($files as $file) { 105 | $filename = $file->getFilename(); 106 | $fileRealPath = $file->getRealPath(); 107 | 108 | // Do not add directories (will be added with the corresponding file anyways). 109 | if ($file->isDir()) { 110 | continue; 111 | } 112 | 113 | if ($filename === 'ext_emconf.php') { 114 | $emConfValidationErrors = (new EmConfVersionValidator($fileRealPath))->collectErrors($this->version); 115 | } 116 | 117 | // Add the files including their directories 118 | $zipArchive->addFile($fileRealPath, substr($fileRealPath, strlen($fullPath) + 1)); 119 | } 120 | 121 | if ($emConfValidationErrors !== []) { 122 | throw new FormDataProcessingException($this->formatEmConfValidationErrors($emConfValidationErrors), 1605563410); 123 | } 124 | 125 | $zipArchive->close(); 126 | 127 | return $this->getVersionFilePath(); 128 | } 129 | 130 | /** 131 | * Extract the given artefact (from either local or remote), 132 | * store it in a temporary transaction path and finally call 133 | * createZipArchiveFromPath() to create the final ZipArchive. 134 | * 135 | * @param string $filename The filename of the artefact to create the ZipArchive from 136 | * @return string The full path to the ZipArchive 137 | */ 138 | public function createZipArchiveFromArtefact(string $filename): string 139 | { 140 | // Only process files with .zip extension 141 | if (!preg_match('/\.zip$/', $filename)) { 142 | throw new FormDataProcessingException('Can only process \'.zip\' files.', 1605562904); 143 | } 144 | // Check if we deal with a remote file 145 | if (preg_match('/^http[s]?:\/\//', $filename)) { 146 | $tempFilename = $this->transactionPath . '/remote-archive-' . $this->getVersionFilename(true) . '.zip'; 147 | // Save the remote file temporary on local disk for validation and creation of the final ZipArchive 148 | if (file_put_contents($tempFilename, fopen($filename, 'rb')) === false) { 149 | throw new FormDataProcessingException('Could not processed remote file.', 1605562356); 150 | } 151 | $filename = $tempFilename; 152 | } 153 | $filename = realpath($filename) ?: ''; 154 | if (!is_file($filename)) { 155 | throw new FormDataProcessingException('No such file.', 1605562482); 156 | } 157 | $zipArchive = new \ZipArchive(); 158 | $zipFile = $zipArchive->open($filename); 159 | if (!$zipFile || $zipArchive->numFiles <= 0) { 160 | throw new FormDataProcessingException('No files in given directory.', 1605562663); 161 | } 162 | $firstNameIndex = $zipArchive->getNameIndex(0) ?: ''; 163 | $extractPath = $this->transactionPath . '/temp-' . $this->getVersionFilename(true); 164 | // If we deal with e.g. Github release zip files, the extension is wrapped into another 165 | // directory. Therefore we have to add the root path here since the final ZipArchive is 166 | // required to provide all extension files on root level. 167 | $rootFolderPath = preg_match('/\/$/', $firstNameIndex) ? '/' . trim($firstNameIndex, '/') : ''; 168 | // Extract the given zip file so we can validate the content 169 | // and create a proper ZipArchive for the request. 170 | $zipArchive->extractTo($extractPath); 171 | $zipArchive->close(); 172 | $this->createZipArchiveFromPath($extractPath . $rootFolderPath); 173 | 174 | return $this->getVersionFilePath(); 175 | } 176 | 177 | /** 178 | * Return the full path to the composed version file 179 | * 180 | * @return string The full path to the version file 181 | * @throws FormDataProcessingException Thrown if path can not be determined 182 | */ 183 | public function getVersionFilePath(): string 184 | { 185 | $versionFilePath = realpath($this->getVersionFilename()); 186 | 187 | if (!$versionFilePath) { 188 | throw new FormDataProcessingException('Could not find version file in given path.', 1605562674); 189 | } 190 | 191 | return $versionFilePath; 192 | } 193 | 194 | /** 195 | * Return the composed version filename with the proper patter 196 | * 197 | * @param bool $hash If TRUE, a hash of the version filename will be returned 198 | * @return string The version filename, or its md5 hash 199 | */ 200 | protected function getVersionFilename(bool $hash = false): string 201 | { 202 | $filename = sprintf('%s/%s_%s.zip', $this->transactionPath, $this->extension, $this->version); 203 | 204 | return $hash ? md5($filename) : $filename; 205 | } 206 | 207 | /** 208 | * Return the configuration for directories and files which 209 | * should be excluded from packaging (the final ZipArchive). 210 | * 211 | * @return array 212 | */ 213 | protected function getExcludeConfiguration(): array 214 | { 215 | $exludeConfigurationFile = Variables::has('TYPO3_EXCLUDE_FROM_PACKAGING') 216 | ? Variables::get('TYPO3_EXCLUDE_FROM_PACKAGING') 217 | : self::EXCLUDE_FROM_PACKAGING; 218 | 219 | if (!file_exists($exludeConfigurationFile)) { 220 | throw new \InvalidArgumentException( 221 | 'The exclude from packaging configuration file \'' . $exludeConfigurationFile . '\' does not exist.', 222 | 1605734677 223 | ); 224 | } 225 | 226 | $configuration = require $exludeConfigurationFile; 227 | 228 | if (!is_array($configuration) || !isset($configuration['directories'], $configuration['files'])) { 229 | throw new RequiredConfigurationMissing( 230 | 'Given exclude from packaging configuration must include \'directories\' and \'files\'.', 231 | 1605734681 232 | ); 233 | } 234 | 235 | return $configuration; 236 | } 237 | 238 | /** 239 | * @param list $errors 240 | */ 241 | private function formatEmConfValidationErrors(array $errors): string 242 | { 243 | $messageParts = ['Validation of `ext_emconf.php` file failed due to the following errors:']; 244 | 245 | foreach ($errors as $error) { 246 | $messageParts[] = ' * ' . EmConfValidationError::getErrorMessage($error); 247 | } 248 | 249 | return implode(PHP_EOL, $messageParts); 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /src/Validation/EmConfValidationError.php: -------------------------------------------------------------------------------- 1 | emConfFilePath = $filePath; 32 | } 33 | 34 | /** 35 | * @return list List of validation errors. If list is empty, ext_emconf.php file is valid. 36 | */ 37 | public function collectErrors(string $givenVersion): array 38 | { 39 | if (!file_exists($this->emConfFilePath)) { 40 | return [EmConfValidationError::NOT_FOUND]; 41 | } 42 | 43 | $_EXTKEY = 'dummy'; 44 | @include $this->emConfFilePath; 45 | 46 | if (!isset($EM_CONF)) { 47 | return [EmConfValidationError::MISSING_CONFIGURATION]; 48 | } 49 | 50 | $emConfDetails = reset($EM_CONF); 51 | 52 | if (!is_array($emConfDetails)) { 53 | return [EmConfValidationError::UNSUPPORTED_TYPE]; 54 | } 55 | 56 | $errors = []; 57 | 58 | if (!isset($emConfDetails['version'])) { 59 | $errors[] = EmConfValidationError::MISSING_EXTENSION_VERSION; 60 | } elseif ((string)$emConfDetails['version'] !== $givenVersion) { 61 | $errors[] = EmConfValidationError::EXTENSION_VERSION_MISMATCH; 62 | } 63 | if (!isset($emConfDetails['constraints']['depends']['typo3'])) { 64 | $errors[] = EmConfValidationError::MISSING_TYPO3_VERSION_CONSTRAINT; 65 | } 66 | 67 | return $errors; 68 | } 69 | 70 | /** 71 | * @param string $givenVersion 72 | * @return bool TRUE if the ext_emconf is valid, FALSE otherwise 73 | */ 74 | public function isValid(string $givenVersion): bool 75 | { 76 | return $this->collectErrors($givenVersion) === []; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Validation/VersionValidator.php: -------------------------------------------------------------------------------- 1 | 999) { 36 | return false; 37 | } 38 | } 39 | return true; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Writer/ConsoleWriter.php: -------------------------------------------------------------------------------- 1 | io = $io; 36 | $this->messages = $messages; 37 | $this->resultFormat = $resultFormat; 38 | } 39 | 40 | public function __call(string $name, array $arguments) 41 | { 42 | if (is_callable([$this->io, $name])) { 43 | $this->io->{$name}(...$arguments); 44 | } 45 | } 46 | 47 | public function writeTitle(): void 48 | { 49 | $this->io->title($this->messages->getTitle()); 50 | } 51 | 52 | public function writeSuccess(): void 53 | { 54 | $this->io->success($this->messages->getSuccess()); 55 | } 56 | 57 | public function writeFailure(string $reason): void 58 | { 59 | $this->io->warning($this->messages->getFailure() . PHP_EOL . 'Reason: ' . $reason); 60 | } 61 | 62 | public function writeFormattedResult(array $content): void 63 | { 64 | foreach ((new ConsoleFormatter($this->resultFormat))->format($content)->getParts() as $part) { 65 | $this->io->{$part->getOutputStyle()}(...$part->getValues()); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /tests/Unit/Environment/VariablesTest.php: -------------------------------------------------------------------------------- 1 | expectExceptionCode(1610442954); 42 | $this->expectException(InvalidComposerJsonException::class); 43 | file_put_contents(self::COMPOSER_FILE, ''); 44 | new ComposerReader('tmp'); 45 | } 46 | 47 | /** 48 | * @test 49 | */ 50 | public function throwsExceptionOnInvalidComposerJsonFile(): void 51 | { 52 | $this->expectExceptionCode(1610442954); 53 | $this->expectException(InvalidComposerJsonException::class); 54 | $composerContent = file_get_contents(__DIR__ . '/../Fixtures/EmConf/emconf_valid.php'); 55 | file_put_contents(self::COMPOSER_FILE, $composerContent); 56 | new ComposerReader('tmp'); 57 | } 58 | 59 | /** 60 | * @test 61 | */ 62 | public function returnEmptyStringIfExtensionKeyNotGiven(): void 63 | { 64 | $composerContent = file_get_contents(__DIR__ . '/../Fixtures/Composer/composer_no_extension_key.json'); 65 | file_put_contents(self::COMPOSER_FILE, $composerContent); 66 | $subject = new ComposerReader('tmp'); 67 | self::assertEmpty($subject->getExtensionKey()); 68 | } 69 | 70 | /** 71 | * @test 72 | */ 73 | public function readCorrectExtensionKeyFromGivenComposerJsonFile(): void 74 | { 75 | $composerContent = file_get_contents(__DIR__ . '/../Fixtures/Composer/composer_with_extension_key.json'); 76 | file_put_contents(self::COMPOSER_FILE, $composerContent); 77 | $subject = new ComposerReader('tmp'); 78 | self::assertSame('my-extension', $subject->getExtensionKey()); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /tests/Unit/Filesystem/VersionReplacerTest.php: -------------------------------------------------------------------------------- 1 | setVersion($tempFile, '["\']version["\']\s=>\s["\']((?:[0-9]+)\.[0-9]+\.[0-9]+\s*)["\']'); 30 | $contents = file_get_contents($tempFile); 31 | self::assertStringContainsString('\'version\' => \'6.9.0\'', $contents); 32 | unlink($tempFile); 33 | } 34 | 35 | /** 36 | * @return \Generator 37 | */ 38 | public static function replaceVersionReplacesProperReleaseOfDocumentationConfigurationDataProvider(): \Generator 39 | { 40 | yield 'guides.xml' => ['guides.xml', 'release="([0-9]+\.[0-9]+\.[0-9]+)"', 'release="6.9.0"']; 41 | yield 'Settings.cfg' => ['Settings.cfg', 'release\s*=\s*([0-9]+\.[0-9]+\.[0-9]+)', 'release=6.9.0']; 42 | } 43 | 44 | /** 45 | * @test 46 | * @dataProvider replaceVersionReplacesProperReleaseOfDocumentationConfigurationDataProvider 47 | */ 48 | public function replaceVersionReplacesProperReleaseOfDocumentationConfiguration( 49 | string $docSettingsFile, 50 | string $docReleasePattern, 51 | string $expected 52 | ): void { 53 | $docSettings = file_get_contents(__DIR__ . '/../Fixtures/Documentation/' . $docSettingsFile); 54 | $tempFile = tempnam(sys_get_temp_dir(), 'tailor_' . $docSettingsFile); 55 | file_put_contents($tempFile, $docSettings); 56 | $subject = new VersionReplacer('6.9.0'); 57 | $subject->setVersion($tempFile, $docReleasePattern); 58 | $contents = file_get_contents($tempFile); 59 | self::assertStringContainsString($expected, preg_replace('/\s+/', '', $contents)); 60 | unlink($tempFile); 61 | } 62 | 63 | /** 64 | * @return \Generator 65 | */ 66 | public static function replaceVersionReplacesProperVersionOfDocumentationConfigurationDataProvider(): \Generator 67 | { 68 | yield 'guides.xml' => ['guides.xml', 'version="([0-9]+\.[0-9]+)"', 'version="6.9"']; 69 | yield 'Settings.cfg' => ['Settings.cfg', 'version\s*=\s*([0-9]+\.[0-9]+)', 'version=6.9']; 70 | } 71 | 72 | /** 73 | * @test 74 | * @dataProvider replaceVersionReplacesProperVersionOfDocumentationConfigurationDataProvider 75 | */ 76 | public function replaceVersionReplacesProperVersionOfDocumentationConfiguration( 77 | string $docSettingsFile, 78 | string $docVersionPattern, 79 | string $expected 80 | ): void { 81 | $docSettings = file_get_contents(__DIR__ . '/../Fixtures/Documentation/' . $docSettingsFile); 82 | $tempFile = tempnam(sys_get_temp_dir(), 'tailor_' . $docSettingsFile); 83 | file_put_contents($tempFile, $docSettings); 84 | $subject = new VersionReplacer('6.9.0'); 85 | $subject->setVersion($tempFile, $docVersionPattern, 2); 86 | $contents = file_get_contents($tempFile); 87 | self::assertStringContainsString($expected, preg_replace('/\s+/', '', $contents)); 88 | unlink($tempFile); 89 | } 90 | 91 | /** 92 | * @test 93 | */ 94 | public function replaceVersionThrowsExceptionOnInvalidFile(): void 95 | { 96 | $this->expectExceptionCode(1605741968); 97 | (new VersionReplacer('6.9.0'))->setVersion('some/invalid/file/path.php', 'version\s*=\s*([0-9.]+)'); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /tests/Unit/Fixtures/Composer/composer_no_extension_key.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "acme/my_extension", 3 | "type": "typo3-cms-extension", 4 | "description": "Great extension - everyone needs it", 5 | "authors": [ 6 | { 7 | "name": "John Doe", 8 | "role": "Dev" 9 | } 10 | ], 11 | "license": [ 12 | "GPL-2.0-or-later" 13 | ], 14 | "require": { 15 | "typo3/cms-core": "^10.4" 16 | }, 17 | "replace": { 18 | "typo3-ter/news": "self.version" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /tests/Unit/Fixtures/Composer/composer_with_extension_key.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "acme/my_extension", 3 | "type": "typo3-cms-extension", 4 | "description": "Great extension - everyone needs it", 5 | "authors": [ 6 | { 7 | "name": "John Doe", 8 | "role": "Dev" 9 | } 10 | ], 11 | "license": [ 12 | "GPL-2.0-or-later" 13 | ], 14 | "require": { 15 | "typo3/cms-core": "^10.4" 16 | }, 17 | "replace": { 18 | "typo3-ter/news": "self.version" 19 | }, 20 | "extra": { 21 | "typo3/cms": { 22 | "extension-key": "my-extension" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Unit/Fixtures/Documentation/Settings.cfg: -------------------------------------------------------------------------------- 1 | [general] 2 | 3 | project = My extension 4 | release = 1.0.0 5 | version = 1.0 6 | t3author = John Doe 7 | copyright = 2021 8 | description = Great extension - everyone needs it 9 | 10 | [notify] 11 | 12 | about_new_builds = no 13 | 14 | [html_theme_options] 15 | 16 | github_branch = main 17 | -------------------------------------------------------------------------------- /tests/Unit/Fixtures/Documentation/guides.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 13 | 14 | -------------------------------------------------------------------------------- /tests/Unit/Fixtures/EmConf/emconf_invalid.php: -------------------------------------------------------------------------------- 1 | true, 5 | ]; 6 | -------------------------------------------------------------------------------- /tests/Unit/Fixtures/EmConf/emconf_no_structure.php: -------------------------------------------------------------------------------- 1 | 'YES', 5 | ]; 6 | -------------------------------------------------------------------------------- /tests/Unit/Fixtures/EmConf/emconf_no_version.php: -------------------------------------------------------------------------------- 1 | 'YES', 5 | ]; 6 | -------------------------------------------------------------------------------- /tests/Unit/Fixtures/EmConf/emconf_valid.php: -------------------------------------------------------------------------------- 1 | 'My extension', 5 | 'description' => 'Great extension - everyone needs it', 6 | 'category' => 'be', 7 | 'author' => 'John Doe', 8 | 'author_email' => 'john@acme.com', 9 | 'state' => 'stable', 10 | 'uploadfolder' => 0, 11 | 'clearCacheOnLoad' => 1, 12 | 'author_company' => 'ACME Corporation', 13 | 'version' => '1.0.0', 14 | 'constraints' => [ 15 | 'depends' => [ 16 | 'typo3' => '10.0.0-11.99.99', 17 | ], 18 | ], 19 | ]; 20 | -------------------------------------------------------------------------------- /tests/Unit/Fixtures/EmConf/emconf_valid_string_array_key.php: -------------------------------------------------------------------------------- 1 | 'My extension', 5 | 'description' => 'Great extension - everyone needs it', 6 | 'category' => 'be', 7 | 'author' => 'John Doe', 8 | 'author_email' => 'john@acme.com', 9 | 'state' => 'stable', 10 | 'uploadfolder' => 0, 11 | 'clearCacheOnLoad' => 1, 12 | 'author_company' => 'ACME Corporation', 13 | 'version' => '1.0.0', 14 | 'constraints' => [ 15 | 'depends' => [ 16 | 'typo3' => '10.0.0-11.99.99', 17 | ], 18 | ], 19 | ]; 20 | -------------------------------------------------------------------------------- /tests/Unit/Fixtures/ExcludeFromPackaging/config_invalid.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'dummy', 6 | ], 7 | ]; 8 | -------------------------------------------------------------------------------- /tests/Unit/Fixtures/ExcludeFromPackaging/config_valid.php: -------------------------------------------------------------------------------- 1 | [ 5 | 'dummy', 6 | ], 7 | 'files' => [ 8 | 'dummy', 9 | ], 10 | ]; 11 | -------------------------------------------------------------------------------- /tests/Unit/Formatter/ConsoleFormatterTest.php: -------------------------------------------------------------------------------- 1 | format($content); 37 | 38 | self::assertSame(count($expectedValues), $formattedParts->count()); 39 | 40 | foreach ($formattedParts->getParts() as $part) { 41 | self::assertSame($part->getValues(), array_shift($expectedValues)); 42 | self::assertSame($part->getOutputStyle(), array_shift($expectedOutputStyle) ?? OutputPart::OUTPUT_WRITE_LINE); 43 | } 44 | } 45 | 46 | /** 47 | * Data provider for formatReturnsFormattedParts 48 | * 49 | * @return \Generator 50 | */ 51 | public function formatReturnsFormattedPartsDataProvider(): \Generator 52 | { 53 | yield 'No output' => [ 54 | [ 55 | 'some' => [ 56 | 'dummy' => 'data', 57 | ], 58 | ], 59 | [], 60 | [], 61 | ConsoleFormatter::FORMAT_NONE, 62 | ]; 63 | yield 'Simple key/values array' => [ 64 | [ 65 | 'dummy' => 'data', 66 | 'noKey', 67 | 'notKeyValue' => [ 68 | 'foo' => 'bar', 69 | ], 70 | 'some_Key' => 'otherData', 71 | ], 72 | [ 73 | ['Dummy: data'], 74 | ['noKey'], 75 | ['Some Key: otherData'], 76 | ], 77 | [], 78 | ConsoleFormatter::FORMAT_KEY_VALUE, 79 | ]; 80 | yield 'Extension details list' => [ 81 | [ 82 | 'key' => 'some_ext', 83 | 'downloads' => 4321, 84 | 'version_count' => 2, 85 | 'meta' => [ 86 | 'composer_name' => 'vendor/some_ext', 87 | 'paypal_url' => '', 88 | 'tags' => [ 89 | [ 90 | 'title' => 'sometag', 91 | ], 92 | [ 93 | 'title' => 'anothertag', 94 | ], 95 | ], 96 | ], 97 | 'current_version' => [ 98 | 'title' => 'foobar', 99 | 'description' => 'barbaz', 100 | 'number' => '1.0.0', 101 | 'state' => 'stable', 102 | 'category' => 'be', 103 | 'typo3_versions' => [ 104 | 9, 10, 105 | ], 106 | 'dependencies' => [ 107 | 'typo3' => '10.0.0 - 10.99.99', 108 | ], 109 | 'conflicts' => [ 110 | 'templavoila' => '*', 111 | ], 112 | 'downloads' => 1234, 113 | 'upload_date' => 1606400890, 114 | 'review_state' => 0, 115 | 'download' => [ 116 | 'composer' => 'composer req vendor/some_ext', 117 | 'zip' => 'https://extensions.typo3.org/extension/download/some_ext/1.0.0/zip', 118 | 't3x' => 'https://extensions.typo3.org/extension/download/some_ext/1.0.0/t3x', 119 | ], 120 | 'author' => [ 121 | 'name' => 'John Doe', 122 | 'email' => 'some-mail@example.com', 123 | 'company' => 'ACME Inc', 124 | ], 125 | ], 126 | ], 127 | [ 128 | ['Key: some_ext'], 129 | ['Downloads: 4321'], 130 | ['Version count: 2'], 131 | [PHP_EOL . 'Meta'], 132 | ['Composer name: vendor/some_ext'], 133 | [PHP_EOL . 'Tags'], 134 | ['Title: sometag'], 135 | ['Title: anothertag'], 136 | [PHP_EOL . 'Current version'], 137 | ['Title: foobar'], 138 | ['Description: barbaz'], 139 | ['Number: 1.0.0'], 140 | ['State: stable'], 141 | ['Category: be'], 142 | [PHP_EOL . 'Typo3 versions'], 143 | ['9'], 144 | ['10'], 145 | [PHP_EOL . 'Dependencies'], 146 | ['Typo3: 10.0.0 - 10.99.99'], 147 | [PHP_EOL . 'Conflicts'], 148 | ['Templavoila: *'], 149 | ['Downloads: 1234'], 150 | ['Upload date: 1606400890'], 151 | ['Review state: 0'], 152 | [PHP_EOL . 'Download'], 153 | ['Composer: composer req vendor/some_ext'], 154 | ['Zip: https://extensions.typo3.org/extension/download/some_ext/1.0.0/zip'], 155 | ['T3x: https://extensions.typo3.org/extension/download/some_ext/1.0.0/t3x'], 156 | [PHP_EOL . 'Author'], 157 | ['Name: John Doe'], 158 | ['Email: some-mail@example.com'], 159 | ['Company: ACME Inc'], 160 | ], 161 | [], 162 | ConsoleFormatter::FORMAT_DETAIL, 163 | ]; 164 | yield 'Details with empty array' => [ 165 | [ 166 | 'key' => 'some_ext', 167 | 'downloads' => 60, 168 | 'version_count' => 2, 169 | 'meta' => [ 170 | 'composer_name' => 'vendor/some_ext', 171 | 'tags' => [], 172 | ], 173 | 'current_version' => [], 174 | ], 175 | [ 176 | ['Key: some_ext'], 177 | ['Downloads: 60'], 178 | ['Version count: 2'], 179 | [PHP_EOL . 'Meta'], 180 | ['Composer name: vendor/some_ext'], 181 | ], 182 | [], 183 | ConsoleFormatter::FORMAT_DETAIL, 184 | ]; 185 | yield 'Find extensions result' => [ 186 | [ 187 | 'results' => 2, 188 | 'page' => 1, 189 | 'per_page' => 2, 190 | 'filter' => [ 191 | 'username' => 'some_user', 192 | ], 193 | 'extensions' => [ 194 | [ 195 | 'key' => 'some_ext', 196 | 'meta' => [ 197 | 'composer_name' => 'vendor/some_ext', 198 | ], 199 | 'current_version' => [ 200 | 'title' => 'foobar', 201 | 'number' => '1.0.0', 202 | 'upload_date' => 1605785659, 203 | ], 204 | ], 205 | [ 206 | 'key' => 'another_ext', 207 | ], 208 | ], 209 | ], 210 | [ 211 | [ 212 | ['Extension Key', 'Title', 'Latest Version', 'Last Updated on', 'Composer Name'], 213 | [ 214 | 'another_ext' => ['another_ext', '-', '-', '-', '-'], 215 | 'some_ext' => ['some_ext', 'foobar', '1.0.0', '19.11.2020', 'vendor/some_ext'], 216 | ], 217 | ], 218 | ['Page: 1, Per page: 2, Filter: some_user (Author)'], 219 | ], 220 | [ 221 | OutputPart::OUTPUT_TABLE, 222 | OutputPart::OUTPUT_WRITE_LINE, 223 | ], 224 | ConsoleFormatter::FORMAT_TABLE, 225 | ]; 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /tests/Unit/Helper/CommandHelperTest.php: -------------------------------------------------------------------------------- 1 | definition = new InputDefinition(); 37 | $this->input = new ArrayInput([], $this->definition); 38 | } 39 | 40 | /** 41 | * @test 42 | */ 43 | public function getExtensionKeyFromInputThrowsExceptionIfInputHasNoArgumentDefined(): void 44 | { 45 | $this->expectException(ExtensionKeyMissingException::class); 46 | $this->expectExceptionMessage('The extension key must either be set as argument, as environment variable or in the composer.json.'); 47 | $this->expectExceptionCode(1605706548); 48 | 49 | CommandHelper::getExtensionKeyFromInput($this->input); 50 | } 51 | 52 | /** 53 | * @test 54 | */ 55 | public function getExtensionKeyFromInputReturnsExtensionKeyFromInputArgument(): void 56 | { 57 | $this->definition->addArgument(new InputArgument('extensionkey', InputArgument::REQUIRED)); 58 | $this->input->setArgument('extensionkey', 'foo'); 59 | 60 | self::assertSame('foo', CommandHelper::getExtensionKeyFromInput($this->input)); 61 | } 62 | 63 | /** 64 | * @test 65 | */ 66 | public function getExtensionKeyFromInputIgnoresEmptyInputArgumentValue(): void 67 | { 68 | $this->expectException(ExtensionKeyMissingException::class); 69 | $this->expectExceptionMessage('The extension key must either be set as argument, as environment variable or in the composer.json.'); 70 | $this->expectExceptionCode(1605706548); 71 | 72 | $this->definition->addArgument(new InputArgument('extensionkey', InputArgument::OPTIONAL)); 73 | $this->input->setArgument('extensionkey', ''); 74 | 75 | CommandHelper::getExtensionKeyFromInput($this->input); 76 | } 77 | 78 | /** 79 | * @test 80 | */ 81 | public function getExtensionKeyFromInputReturnsExtensionKeyFromEnvironmentVariables(): void 82 | { 83 | putenv('TYPO3_EXTENSION_KEY=foo'); 84 | 85 | self::assertSame('foo', CommandHelper::getExtensionKeyFromInput($this->input)); 86 | 87 | putenv('TYPO3_EXTENSION_KEY'); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /tests/Unit/HttpClientFactoryTest.php: -------------------------------------------------------------------------------- 1 | requestConfiguration = new RequestConfiguration( 27 | 'GET', 28 | '/endpoint', 29 | ['parameter' => 'value'], 30 | ['data' => 'some value'], 31 | ['accept' => 'application/xml'] 32 | ); 33 | } 34 | 35 | /** 36 | * @test 37 | */ 38 | public function createHttpClientThrowsExceptionOnMissingCredentials(): void 39 | { 40 | $this->expectExceptionCode(1606995339); 41 | HttpClientFactory::create($this->requestConfiguration); 42 | } 43 | 44 | /** 45 | * @test 46 | */ 47 | public function createHttpClientWithDefaultsTest(): void 48 | { 49 | unset($_ENV); 50 | $_ENV['TYPO3_REMOTE_BASE_URI'] = ''; 51 | $_ENV['TYPO3_API_VERSION'] = ''; 52 | $_ENV['TYPO3_API_TOKEN'] = 'token123'; 53 | 54 | $httpClient = HttpClientFactory::create($this->requestConfiguration); 55 | $defaultOptions = $this->getDefaultOptions($httpClient); 56 | 57 | self::assertSame( 58 | 'https://extensions.typo3.org/api/v1/', 59 | $defaultOptions['base_uri'] 60 | ); 61 | 62 | self::assertContains('accept: application/xml', $defaultOptions['headers']); 63 | self::assertContains('User-Agent: Tailor - Your TYPO3 Extension Helper', $defaultOptions['headers']); 64 | 65 | self::assertSame(['parameter' => 'value'], $defaultOptions['query']); 66 | self::assertSame('data=some+value', $defaultOptions['body']); 67 | } 68 | 69 | /** 70 | * @test 71 | */ 72 | public function createHttpClientWithFallbackTest(): void 73 | { 74 | unset($_ENV); 75 | $_ENV['TYPO3_API_TOKEN'] = 'token123'; 76 | 77 | $httpClient = HttpClientFactory::create($this->requestConfiguration); 78 | 79 | self::assertSame( 80 | 'https://extensions.typo3.org/api/v1/', 81 | $this->getDefaultOptions($httpClient)['base_uri'] 82 | ); 83 | } 84 | 85 | /** 86 | * @test 87 | */ 88 | public function createHttpClientWithOverrideTest(): void 89 | { 90 | unset($_ENV); 91 | $_ENV['TYPO3_REMOTE_BASE_URI'] = 'some_remote'; 92 | $_ENV['TYPO3_API_VERSION'] = 'v123'; 93 | $_ENV['TYPO3_API_TOKEN'] = 'token123'; 94 | 95 | $httpClient = HttpClientFactory::create($this->requestConfiguration); 96 | 97 | self::assertSame( 98 | 'some_remote/api/v123/', 99 | $this->getDefaultOptions($httpClient)['base_uri'] 100 | ); 101 | } 102 | 103 | /** 104 | * @test 105 | */ 106 | public function createHttpClientWithOverridePutenvTest(): void 107 | { 108 | unset($_ENV); 109 | putenv('TYPO3_REMOTE_BASE_URI=some_other_remote'); 110 | putenv('TYPO3_API_VERSION=v123'); 111 | $_ENV['TYPO3_API_VERSION'] = 'v321'; 112 | putenv('TYPO3_API_TOKEN=token123'); 113 | 114 | $httpClient = HttpClientFactory::create($this->requestConfiguration); 115 | 116 | self::assertSame( 117 | 'some_other_remote/api/v321/', 118 | $this->getDefaultOptions($httpClient)['base_uri'] 119 | ); 120 | } 121 | 122 | /** 123 | * @test 124 | */ 125 | public function createHttpClientWithBearerAuthTest(): void 126 | { 127 | unset($_ENV); 128 | $_ENV['TYPO3_API_TOKEN'] = 'token123'; 129 | $_ENV['TYPO3_API_USERNAME'] = 'user'; 130 | $_ENV['TYPO3_API_PASSWORD'] = 'pass'; 131 | 132 | $httpClient = HttpClientFactory::create($this->requestConfiguration); 133 | 134 | self::assertSame('token123', $this->getDefaultOptions($httpClient)['auth_bearer']); 135 | } 136 | 137 | /** 138 | * @test 139 | */ 140 | public function createHttpClientWithBearerAuthOverrideTest(): void 141 | { 142 | unset($_ENV); 143 | putenv('TYPO3_API_TOKEN=token123'); 144 | $_ENV['TYPO3_API_TOKEN'] = 'token321'; 145 | putenv('TYPO3_API_USERNAME=user'); 146 | putenv('TYPO3_API_PASSWORD=pass'); 147 | 148 | $httpClient = HttpClientFactory::create($this->requestConfiguration); 149 | 150 | self::assertSame('token321', $this->getDefaultOptions($httpClient)['auth_bearer']); 151 | } 152 | 153 | /** 154 | * @test 155 | */ 156 | public function createHttpClientWithBasicAuthTest(): void 157 | { 158 | unset($_ENV); 159 | putenv('TYPO3_API_TOKEN='); 160 | putenv('TYPO3_API_USERNAME=user'); 161 | $_ENV['TYPO3_API_USERNAME'] = 'overridenUser'; 162 | putenv('TYPO3_API_PASSWORD=pass'); 163 | 164 | $httpClient = HttpClientFactory::create($this->requestConfiguration); 165 | 166 | self::assertSame('overridenUser:pass', $this->getDefaultOptions($httpClient)['auth_basic']); 167 | } 168 | 169 | /** 170 | * @test 171 | */ 172 | public function createHttpClientWithBasicAuthEnforcedTest(): void 173 | { 174 | unset($_ENV); 175 | putenv('TYPO3_API_TOKEN=someToken123'); 176 | $_ENV['TYPO3_API_TOKEN'] = 'overridenToken'; 177 | putenv('TYPO3_API_USERNAME=user'); 178 | putenv('TYPO3_API_PASSWORD=pass'); 179 | $_ENV['TYPO3_API_PASSWORD'] = 'overridenPass'; 180 | 181 | $httpClient = HttpClientFactory::create( 182 | new RequestConfiguration('GET', '/endpoint', [], [], [], false, HttpClientFactory::BASIC_AUTH) 183 | ); 184 | 185 | self::assertSame('user:overridenPass', $this->getDefaultOptions($httpClient)['auth_basic']); 186 | } 187 | 188 | protected function getDefaultOptions(HttpClientInterface $httpClient): array 189 | { 190 | $defaultOptions = (new \ReflectionClass($httpClient))->getProperty('defaultOptions'); 191 | $defaultOptions->setAccessible(true); 192 | 193 | return $defaultOptions->getValue($httpClient); 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /tests/Unit/Service/VersionServiceTest.php: -------------------------------------------------------------------------------- 1 | invokeMethod('getExcludeConfiguration', [])['directories'] 31 | ); 32 | } 33 | 34 | /** 35 | * @test 36 | */ 37 | public function defaultExcludeFromPackagingConfigurationIsUsedOnEmptyPath(): void 38 | { 39 | unset($_ENV); 40 | putenv('TYPO3_EXCLUDE_FROM_PACKAGING='); 41 | $_ENV['TYPO3_EXCLUDE_FROM_PACKAGING'] = ''; 42 | 43 | self::assertContains( 44 | 'vendor', 45 | $this->invokeMethod('getExcludeConfiguration', [])['directories'] 46 | ); 47 | } 48 | 49 | /** 50 | * @test 51 | */ 52 | public function customExcludeFromPackagingConfigurationIsUsed(): void 53 | { 54 | unset($_ENV); 55 | putenv('TYPO3_EXCLUDE_FROM_PACKAGING='); 56 | $_ENV['TYPO3_EXCLUDE_FROM_PACKAGING'] = __DIR__ . '/../Fixtures/ExcludeFromPackaging/config_valid.php'; 57 | 58 | self::assertSame( 59 | ['directories' => ['dummy'], 'files' => ['dummy']], 60 | $this->invokeMethod('getExcludeConfiguration', []) 61 | ); 62 | } 63 | 64 | /** 65 | * @test 66 | */ 67 | public function throwsExceptionOnMissingCustomConfiguration(): void 68 | { 69 | unset($_ENV); 70 | putenv('TYPO3_EXCLUDE_FROM_PACKAGING=' . __DIR__ . '/../Fixtures/ExcludeFromPackaging/config_invalid_path.php'); 71 | 72 | $this->expectException(\InvalidArgumentException::class); 73 | $this->expectExceptionCode(1605734677); 74 | 75 | new VersionService('1.0.0', 'my_ext', '/dummyPath'); 76 | } 77 | 78 | /** 79 | * @test 80 | */ 81 | public function throwsExceptionOnInvalidCustomConfiguration(): void 82 | { 83 | unset($_ENV); 84 | putenv('TYPO3_EXCLUDE_FROM_PACKAGING=' . __DIR__ . '/../Fixtures/ExcludeFromPackaging/config_invalid.php'); 85 | 86 | $this->expectException(RequiredConfigurationMissing::class); 87 | $this->expectExceptionCode(1605734681); 88 | 89 | new VersionService('1.0.0', 'my_ext', '/dummyPath'); 90 | } 91 | 92 | /** 93 | * @test 94 | */ 95 | public function getVersionFilenameTest(): void 96 | { 97 | unset($_ENV); 98 | putenv('TYPO3_EXCLUDE_FROM_PACKAGING=' . __DIR__ . '/../Fixtures/ExcludeFromPackaging/config_valid.php'); 99 | 100 | self::assertSame( 101 | '/dummyPath/my_ext_1.0.0.zip', 102 | $this->invokeMethod('getVersionFilename', []) 103 | ); 104 | } 105 | 106 | /** 107 | * @test 108 | */ 109 | public function getVersionFilenameAsMd5Test(): void 110 | { 111 | unset($_ENV); 112 | putenv('TYPO3_EXCLUDE_FROM_PACKAGING='); 113 | $_ENV['TYPO3_EXCLUDE_FROM_PACKAGING'] = __DIR__ . '/../Fixtures/ExcludeFromPackaging/config_valid.php'; 114 | 115 | self::assertSame( 116 | 'cf2d6e211e53d983056761055c95791b', 117 | $this->invokeMethod('getVersionFilename', [true]) 118 | ); 119 | } 120 | 121 | /** 122 | * Invoke a protected / private method from VersionService 123 | * 124 | * @param string $methodName 125 | * @param array $arguments 126 | * 127 | * @return mixed 128 | * @throws \ReflectionException 129 | */ 130 | protected function invokeMethod(string $methodName, array $arguments) 131 | { 132 | $mock = $this 133 | ->getMockBuilder(VersionService::class) 134 | ->setConstructorArgs(['1.0.0', 'my_ext', '/dummyPath']) 135 | ->getMock(); 136 | 137 | $method = new \ReflectionMethod(VersionService::class, $methodName); 138 | $method->setAccessible(true); 139 | 140 | return $method->invokeArgs($mock, $arguments); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /tests/Unit/Validation/EmConfVersionValidatorTest.php: -------------------------------------------------------------------------------- 1 | collectErrors('1.2.0')); 29 | } 30 | 31 | /** 32 | * @test 33 | */ 34 | public function collectErrorsReturnsErrorIfConfigurationIsMissing(): void 35 | { 36 | $subject = new EmConfVersionValidator(__DIR__ . '/../Fixtures/EmConf/emconf_invalid.php'); 37 | $expected = [EmConfValidationError::MISSING_CONFIGURATION]; 38 | self::assertSame($expected, $subject->collectErrors('1.0.0')); 39 | } 40 | 41 | /** 42 | * @test 43 | */ 44 | public function collectErrorsReturnsErrorIfFileDoesNotMatchEmConfStructure(): void 45 | { 46 | $subject = new EmConfVersionValidator(__DIR__ . '/../Fixtures/EmConf/emconf_no_structure.php'); 47 | $expected = [EmConfValidationError::UNSUPPORTED_TYPE]; 48 | self::assertSame($expected, $subject->collectErrors('1.0.0')); 49 | } 50 | 51 | /** 52 | * @test 53 | */ 54 | public function collectErrorsReturnsErrorsIfNoVersionGiven(): void 55 | { 56 | $subject = new EmConfVersionValidator(__DIR__ . '/../Fixtures/EmConf/emconf_no_version.php'); 57 | $expected = [ 58 | EmConfValidationError::MISSING_EXTENSION_VERSION, 59 | EmConfValidationError::MISSING_TYPO3_VERSION_CONSTRAINT, 60 | ]; 61 | self::assertSame($expected, $subject->collectErrors('1.0.0')); 62 | } 63 | 64 | /** 65 | * @test 66 | */ 67 | public function collectErrorsReturnsErrorIfVersionsDoNotMatch(): void 68 | { 69 | $subject = new EmConfVersionValidator(__DIR__ . '/../Fixtures/EmConf/emconf_valid.php'); 70 | $expected = [EmConfValidationError::EXTENSION_VERSION_MISMATCH]; 71 | self::assertSame($expected, $subject->collectErrors('2.0.0')); 72 | } 73 | 74 | /** 75 | * @test 76 | */ 77 | public function collectErrorsReturnsEmptyArrayIfFileIsValid(): void 78 | { 79 | $subject = new EmConfVersionValidator(__DIR__ . '/../Fixtures/EmConf/emconf_valid.php'); 80 | self::assertSame([], $subject->collectErrors('1.0.0')); 81 | } 82 | 83 | /** 84 | * @test 85 | */ 86 | public function isInvalidIfNoFileFound(): void 87 | { 88 | $subject = new EmConfVersionValidator(__DIR__ . '/no-file'); 89 | self::assertFalse($subject->isValid('1.2.0')); 90 | } 91 | 92 | /** 93 | * @test 94 | */ 95 | public function isInvalidIfFileDoesNotMatchEmConfStructure(): void 96 | { 97 | $subject = new EmConfVersionValidator(__DIR__ . '/../Fixtures/EmConf/emconf_invalid.php'); 98 | self::assertFalse($subject->isValid('1.0.0')); 99 | } 100 | 101 | /** 102 | * @test 103 | */ 104 | public function isInvalidIfNoVersionGiven(): void 105 | { 106 | $subject = new EmConfVersionValidator(__DIR__ . '/../Fixtures/EmConf/emconf_no_version.php'); 107 | self::assertFalse($subject->isValid('1.0.0')); 108 | } 109 | 110 | /** 111 | * @test 112 | */ 113 | public function isValidMatchesVersion(): void 114 | { 115 | $subject = new EmConfVersionValidator(__DIR__ . '/../Fixtures/EmConf/emconf_valid.php'); 116 | self::assertFalse($subject->isValid('1.2.0')); 117 | self::assertTrue($subject->isValid('1.0.0')); 118 | } 119 | 120 | /** 121 | * @test 122 | */ 123 | public function isValidWithStringArrayKey(): void 124 | { 125 | $subject = new EmConfVersionValidator(__DIR__ . '/../Fixtures/EmConf/emconf_valid_string_array_key.php'); 126 | self::assertTrue($subject->isValid('1.0.0')); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /tests/Unit/Validation/VersionValidatorTest.php: -------------------------------------------------------------------------------- 1 | isValid($input)); 30 | } 31 | 32 | /** 33 | * Data provider for isValidTest 34 | * 35 | * @return \Generator 36 | */ 37 | public function isValidTestDataProvider(): \Generator 38 | { 39 | yield 'Wrong format' => [ 40 | 'v1', 41 | false, 42 | ]; 43 | yield 'Wrong delimiter' => [ 44 | '1-0-0', 45 | false, 46 | ]; 47 | yield 'Missing patch version' => [ 48 | '1.0', 49 | false, 50 | ]; 51 | yield 'Patch version to high' => [ 52 | '1.0.1000', 53 | false, 54 | ]; 55 | yield 'Patch version to low' => [ 56 | '1.0.-12', 57 | false, 58 | ]; 59 | yield 'Not numeric' => [ 60 | '0.2.0-alpha', 61 | false, 62 | ]; 63 | yield 'Valid version' => [ 64 | '1.0.0', 65 | true, 66 | ]; 67 | yield 'Valid version 2' => [ 68 | '10.4.999', 69 | true, 70 | ]; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/bootstrap.php: -------------------------------------------------------------------------------- 1 |