diff --git a/.editorconfig b/.editorconfig index 4bda893015..e366a4e4d3 100644 --- a/.editorconfig +++ b/.editorconfig @@ -11,9 +11,6 @@ end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true -[*.md] -trim_trailing_whitespace = true - [*.yaml] indent_size = 2 @@ -22,3 +19,12 @@ indent_size = 2 [*.go] indent_style = tab + +[*.json] +indent_size = 2 + +[composer.json] +indent_size = 4 + +[*.neon] +indent_style = tab diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1c8360538..ec05b097d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,9 @@ jobs: CI_COMMIT_SHORT_SHA=$(echo $CI_COMMIT_SHA | head -c8) ./bin/platform self:build --no-composer-rebuild --yes --replace-version "$CI_COMMIT_REF_NAME"-"$CI_COMMIT_SHORT_SHA" --output platform.phar + - name: Lint PHP files + run: make lint-php-cs-fixer lint-phpstan + - name: Run PHPUnit tests run: | composer install --no-interaction --no-scripts @@ -55,6 +58,9 @@ jobs: go-version: 1.22 cache-dependency-path: cli/go.sum + - name: Lint Go files + run: make lint-gofmt + - name: Run integration tests run: | export TEST_CLI_PATH=$(realpath "./platform.phar") diff --git a/.gitignore b/.gitignore index 971ad974a0..88a50fd5ac 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ /release-changelog*.md /local-php-security-checker /.phpunit.result.cache +/.phpunit.cache +/.php-cs-fixer.cache diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php new file mode 100644 index 0000000000..2e8713dca9 --- /dev/null +++ b/.php-cs-fixer.dist.php @@ -0,0 +1,18 @@ +in(__DIR__) + ->notPath([ + 'config/cache/container.php', // Ignore generated file + 'dist/installer.php', // Keep old PHP compatibility + 'tests/data', // Ignore test data + ]) +; + +return (new PhpCsFixer\Config()) + ->setRules([ + '@PER-CS' => true, + '@PHP84Migration' => true, + ]) + ->setFinder($finder) +; diff --git a/.platform/services.yaml b/.platform/services.yaml deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000..474db6612e --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ +GO_TESTS_DIR=go-tests + +.PHONY: composer-dev +composer-dev: + composer install --no-interaction + +.PHONY: clean +clean: + rm config/cache/container.php + +.PHONY: lint-phpstan +lint-phpstan: composer-dev + ./vendor/bin/phpstan analyse + +.PHONY: lint-php-cs-fixer +lint-php-cs-fixer: composer-dev + ./vendor/bin/php-cs-fixer check --config .php-cs-fixer.dist.php --diff + +.PHONY: lint-gofmt +lint-gofmt: + cd $(GO_TESTS_DIR) && go fmt ./... + +.PHONY: lint-golangci +lint-golangci: + command -v golangci-lint >/dev/null || go install github.com/golangci/golangci-lint/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) + cd $(GO_TESTS_DIR) && golangci-lint run + +.PHONY: lint +lint: lint-gofmt lint-golangci lint-php-cs-fixer lint-phpstan + +.PHONY: test +test: + ./vendor/bin/phpunit --exclude-group slow + cd $(GO_TESTS_DIR) && go test -v -count=1 ./... diff --git a/box.json b/box.json index 4d4b54484e..d02977c704 100644 --- a/box.json +++ b/box.json @@ -2,14 +2,13 @@ "chmod": "0755", "directories": [ "bin", - "resources", - "src" + "config", + "resources" ], "files": [ "config.yaml", "config-defaults.yaml", "constants.php", - "services.yaml", "shell-config.tmpl.rc", "shell-config-bash.tmpl.rc", "shell-config-bash-direct.tmpl.rc", @@ -18,6 +17,10 @@ "vendor/composer/ca-bundle/res/cacert.pem" ], "finder": [ + { + "in": "src", + "exclude": ["Rector"] + }, { "in": "vendor", "name": "*.php*", @@ -33,7 +36,7 @@ ], "main": "bin/platform", "output": "platform.phar", - "stub": "stub.php", + "stub": "resources/phar-stub.php", "compactors": [ "KevinGH\\Box\\Compactor\\Json", "KevinGH\\Box\\Compactor\\Php" diff --git a/composer.json b/composer.json index 8027011a52..e9a3b4df37 100644 --- a/composer.json +++ b/composer.json @@ -5,28 +5,26 @@ "require": { "php": ">=8.2", "doctrine/cache": "~1.5", - "guzzlehttp/guzzle": "^5.3", - "guzzlehttp/ringphp": "^1.1", - "platformsh/console-form": ">=0.0.37 <2.0", - "platformsh/client": ">=0.87.0 <2.0", - "symfony/console": "^3.0 >=3.2", - "symfony/yaml": "^3.0 || ^2.6", - "symfony/finder": "^3.0", - "symfony/filesystem": "^3.0", - "symfony/process": "^3.0 >=3.4", - "stecman/symfony-console-completion": "^0.11", - "symfony/event-dispatcher": "^3.0", + "guzzlehttp/guzzle": "^7", + "platformsh/console-form": "^1@beta", + "platformsh/client": "^3@beta", + "symfony/console": "^7", + "symfony/yaml": "^7", + "symfony/finder": "^7", + "symfony/filesystem": "^7", + "symfony/process": "^7", + "symfony/event-dispatcher": "^7", "padraic/phar-updater": "^1.0", - "symfony/dependency-injection": "^3.1", - "symfony/config": "^3.1", - "paragonie/random_compat": "^2.0", + "symfony/dependency-injection": "^7", + "symfony/config": "^7", "ext-json": "*", "composer/ca-bundle": "^1.3", "khill/php-duration": "^1.1", - "giggsey/libphonenumber-for-php": "^8.13", "symfony/polyfill-mbstring": "^1.19", "symfony/polyfill-iconv": "^1.19", - "padraic/humbug_get_contents": "dev-allow-php-8 as 1.1.3" + "padraic/humbug_get_contents": "dev-allow-php-8 as 1.1.3", + "platformsh/oauth2": "^1@beta", + "giggsey/libphonenumber-for-php-lite": "^8.13" }, "repositories": [ { @@ -49,7 +47,9 @@ } }, "require-dev": { - "phpunit/phpunit": "^11" + "phpunit/phpunit": "^11", + "rector/rector": "^1.2", + "friendsofphp/php-cs-fixer": "^3.65" }, "authors": [ { @@ -69,6 +69,7 @@ }, "scripts": { "update-countries": "php scripts/update-countries.php", - "update-known-hosts": "php scripts/update-known-hosts.php" + "update-known-hosts": "php scripts/update-known-hosts.php", + "post-install-cmd": ["Platformsh\\Cli\\Application::warmCaches"] } } diff --git a/composer.lock b/composer.lock index 60960b2595..c68e9b24da 100644 --- a/composer.lock +++ b/composer.lock @@ -4,23 +4,24 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ae2e49f7f45d94f973404b409211050a", + "content-hash": "cc408c75eae106a1f4ad1fcb159a96bd", "packages": [ { "name": "cocur/slugify", - "version": "v2.5", + "version": "v3.2", "source": { "type": "git", "url": "https://github.com/cocur/slugify.git", - "reference": "e8167e9a3236044afebd6e8ab13ebeb3ec9ca145" + "reference": "d41701efe58ba2df9cae029c3d21e1518cc6780e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/cocur/slugify/zipball/e8167e9a3236044afebd6e8ab13ebeb3ec9ca145", - "reference": "e8167e9a3236044afebd6e8ab13ebeb3ec9ca145", + "url": "https://api.github.com/repos/cocur/slugify/zipball/d41701efe58ba2df9cae029c3d21e1518cc6780e", + "reference": "d41701efe58ba2df9cae029c3d21e1518cc6780e", "shasum": "" }, "require": { + "ext-mbstring": "*", "php": ">=5.5.9" }, "require-dev": { @@ -30,13 +31,13 @@ "mikey179/vfsstream": "~1.6", "mockery/mockery": "~0.9", "nette/di": "~2.2", - "phpunit/phpunit": "~4.8|~5.2", + "phpunit/phpunit": "~4.8.36|~5.2", "pimple/pimple": "~1.1", "plumphp/plum": "~0.1", "silex/silex": "~1.3", - "symfony/config": "~2.4|~3.0", - "symfony/dependency-injection": "~2.4|~3.0", - "symfony/http-kernel": "~2.4|~3.0", + "symfony/config": "~2.4|~3.0|~4.0", + "symfony/dependency-injection": "~2.4|~3.0|~4.0", + "symfony/http-kernel": "~2.4|~3.0|~4.0", "twig/twig": "~1.26|~2.0", "zendframework/zend-modulemanager": "~2.2", "zendframework/zend-servicemanager": "~2.2", @@ -72,20 +73,20 @@ "issues": "https://github.com/cocur/slugify/issues", "source": "https://github.com/cocur/slugify/tree/master" }, - "time": "2017-03-23T21:52:55+00:00" + "time": "2019-01-31T20:38:55+00:00" }, { "name": "composer/ca-bundle", - "version": "1.5.3", + "version": "1.5.4", "source": { "type": "git", "url": "https://github.com/composer/ca-bundle.git", - "reference": "3b1fc3f0be055baa7c6258b1467849c3e8204eb2" + "reference": "bc0593537a463e55cadf45fd938d23b75095b7e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/ca-bundle/zipball/3b1fc3f0be055baa7c6258b1467849c3e8204eb2", - "reference": "3b1fc3f0be055baa7c6258b1467849c3e8204eb2", + "url": "https://api.github.com/repos/composer/ca-bundle/zipball/bc0593537a463e55cadf45fd938d23b75095b7e1", + "reference": "bc0593537a463e55cadf45fd938d23b75095b7e1", "shasum": "" }, "require": { @@ -132,7 +133,7 @@ "support": { "irc": "irc://irc.freenode.org/composer", "issues": "https://github.com/composer/ca-bundle/issues", - "source": "https://github.com/composer/ca-bundle/tree/1.5.3" + "source": "https://github.com/composer/ca-bundle/tree/1.5.4" }, "funding": [ { @@ -148,7 +149,7 @@ "type": "tidelift" } ], - "time": "2024-11-04T10:15:26+00:00" + "time": "2024-11-27T15:35:25+00:00" }, { "name": "doctrine/cache", @@ -250,85 +251,43 @@ "time": "2022-05-20T20:06:54+00:00" }, { - "name": "firebase/php-jwt", - "version": "v2.2.0", - "source": { - "type": "git", - "url": "https://github.com/firebase/php-jwt.git", - "reference": "e0a75bfb6413f22092c99b70f310ccb2cca3efa5" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/firebase/php-jwt/zipball/e0a75bfb6413f22092c99b70f310ccb2cca3efa5", - "reference": "e0a75bfb6413f22092c99b70f310ccb2cca3efa5", - "shasum": "" - }, - "require": { - "php": ">=5.2.0" - }, - "type": "library", - "autoload": { - "classmap": [ - "Authentication/", - "Exceptions/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Neuman Vong", - "email": "neuman+pear@twilio.com", - "role": "Developer" - }, - { - "name": "Anant Narayanan", - "email": "anant@php.net", - "role": "Developer" - } - ], - "description": "A simple library to encode and decode JSON Web Tokens (JWT) in PHP. Should conform to the current spec.", - "homepage": "https://github.com/firebase/php-jwt", - "support": { - "issues": "https://github.com/firebase/php-jwt/issues", - "source": "https://github.com/firebase/php-jwt/tree/v2.2.0" - }, - "time": "2015-06-22T23:26:39+00:00" - }, - { - "name": "giggsey/libphonenumber-for-php", - "version": "8.13.50", + "name": "giggsey/libphonenumber-for-php-lite", + "version": "8.13.52", "source": { "type": "git", - "url": "https://github.com/giggsey/libphonenumber-for-php.git", - "reference": "ab8b27ded2df369de629af637fa9975dda014078" + "url": "https://github.com/giggsey/libphonenumber-for-php-lite.git", + "reference": "9d48e4e112d4a24d46a5fb7c65d000ca3d3faac1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/giggsey/libphonenumber-for-php/zipball/ab8b27ded2df369de629af637fa9975dda014078", - "reference": "ab8b27ded2df369de629af637fa9975dda014078", + "url": "https://api.github.com/repos/giggsey/libphonenumber-for-php-lite/zipball/9d48e4e112d4a24d46a5fb7c65d000ca3d3faac1", + "reference": "9d48e4e112d4a24d46a5fb7c65d000ca3d3faac1", "shasum": "" }, "require": { - "giggsey/locale": "^2.0", - "php": "^7.4|^8.0", + "php": "^8.1", "symfony/polyfill-mbstring": "^1.17" }, - "replace": { - "giggsey/libphonenumber-for-php-lite": "self.version" + "conflict": { + "giggsey/libphonenumber-for-php": "*" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.64", - "pear/pear-core-minimal": "^1.10", - "pear/pear_exception": "^1.0", + "ext-dom": "*", + "friendsofphp/php-cs-fixer": "^3.12", + "infection/infection": "^0.28", + "pear/pear-core-minimal": "^1.10.11", + "pear/pear_exception": "^1.0.2", "pear/versioncontrol_git": "^0.7", - "phing/phing": "^3.0", - "php-coveralls/php-coveralls": "^2.0", - "phpunit/phpunit": "^9.6", - "symfony/console": "^v5.2", - "symfony/var-exporter": "^5.2" + "phing/phing": "^2.17.4", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-phpunit": "^1.2", + "phpunit/phpunit": "^10.5", + "symfony/console": "^6.0", + "symfony/var-exporter": "^6.0" + }, + "suggest": { + "giggsey/libphonenumber-for-php": "Use libphonenumber-for-php for geocoding, carriers, timezones and matching" }, "type": "library", "extra": { @@ -358,8 +317,8 @@ "homepage": "https://giggsey.com/" } ], - "description": "PHP Port of Google's libphonenumber", - "homepage": "https://github.com/giggsey/libphonenumber-for-php", + "description": "A lite version of giggsey/libphonenumber-for-php, which is a PHP Port of Google's libphonenumber", + "homepage": "https://github.com/giggsey/libphonenumber-for-php-lite", "keywords": [ "geocoding", "geolocation", @@ -369,149 +328,60 @@ "validation" ], "support": { - "issues": "https://github.com/giggsey/libphonenumber-for-php/issues", - "source": "https://github.com/giggsey/libphonenumber-for-php" + "issues": "https://github.com/giggsey/libphonenumber-for-php-lite/issues", + "source": "https://github.com/giggsey/libphonenumber-for-php-lite" }, - "time": "2024-11-18T10:02:13+00:00" + "time": "2024-12-13T09:11:09+00:00" }, { - "name": "giggsey/locale", - "version": "2.7.0", + "name": "guzzlehttp/guzzle", + "version": "7.9.2", "source": { "type": "git", - "url": "https://github.com/giggsey/Locale.git", - "reference": "a5c65ea3c2630f27ccb78977990eefbee6dd8f97" + "url": "https://github.com/guzzle/guzzle.git", + "reference": "d281ed313b989f213357e3be1a179f02196ac99b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/giggsey/Locale/zipball/a5c65ea3c2630f27ccb78977990eefbee6dd8f97", - "reference": "a5c65ea3c2630f27ccb78977990eefbee6dd8f97", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/d281ed313b989f213357e3be1a179f02196ac99b", + "reference": "d281ed313b989f213357e3be1a179f02196ac99b", "shasum": "" }, "require": { - "php": "^7.4|^8.0" - }, - "require-dev": { "ext-json": "*", - "friendsofphp/php-cs-fixer": "^3.64", - "pear/pear-core-minimal": "^1.9", - "pear/pear_exception": "^1.0", - "pear/versioncontrol_git": "^0.5", - "phing/phing": "^2.7", - "php-coveralls/php-coveralls": "^2.0", - "phpunit/phpunit": "^8.5|^9.5", - "symfony/console": "^5.0|^6.0", - "symfony/filesystem": "^5.0|^6.0", - "symfony/finder": "^5.0|^6.0", - "symfony/process": "^5.0|^6.0", - "symfony/var-exporter": "^5.2|^6.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Giggsey\\Locale\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Joshua Gigg", - "email": "giggsey@gmail.com", - "homepage": "https://giggsey.com/" - } - ], - "description": "Locale functions required by libphonenumber-for-php", - "support": { - "issues": "https://github.com/giggsey/Locale/issues", - "source": "https://github.com/giggsey/Locale/tree/2.7.0" - }, - "time": "2024-11-04T11:18:07+00:00" - }, - { - "name": "guzzlehttp/cache-subscriber", - "version": "0.2.0", - "source": { - "type": "git", - "url": "https://github.com/guzzle/cache-subscriber.git", - "reference": "8c766ba399e4c46383e3eaa220201be62abd101e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/cache-subscriber/zipball/8c766ba399e4c46383e3eaa220201be62abd101e", - "reference": "8c766ba399e4c46383e3eaa220201be62abd101e", - "shasum": "" + "guzzlehttp/promises": "^1.5.3 || ^2.0.3", + "guzzlehttp/psr7": "^2.7.0", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" }, - "require": { - "doctrine/cache": "~1.3", - "guzzlehttp/guzzle": "~5.0", - "php": ">=5.4.0" + "provide": { + "psr/http-client-implementation": "1.0" }, "require-dev": { - "phpunit/phpunit": "~4.0" + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "0.2-dev" - } - }, - "autoload": { - "psr-4": { - "GuzzleHttp\\Subscriber\\Cache\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" + "bamarni-bin": { + "bin-links": true, + "forward-command": false } - ], - "description": "Guzzle HTTP cache subscriber", - "homepage": "http://guzzlephp.org/", - "keywords": [ - "Guzzle", - "cache" - ], - "support": { - "issues": "https://github.com/guzzle/cache-subscriber/issues", - "source": "https://github.com/guzzle/cache-subscriber/tree/0.2.0" - }, - "abandoned": true, - "time": "2019-09-16T13:44:55+00:00" - }, - { - "name": "guzzlehttp/guzzle", - "version": "5.3.4", - "source": { - "type": "git", - "url": "https://github.com/guzzle/guzzle.git", - "reference": "b87eda7a7162f95574032da17e9323c9899cb6b2" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b87eda7a7162f95574032da17e9323c9899cb6b2", - "reference": "b87eda7a7162f95574032da17e9323c9899cb6b2", - "shasum": "" - }, - "require": { - "guzzlehttp/ringphp": "^1.1", - "php": ">=5.4.0", - "react/promise": "^2.2" }, - "require-dev": { - "ext-curl": "*", - "phpunit/phpunit": "^4.0" - }, - "type": "library", "autoload": { + "files": [ + "src/functions_include.php" + ], "psr-4": { "GuzzleHttp\\": "src/" } @@ -521,64 +391,105 @@ "MIT" ], "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, { "name": "Michael Dowling", "email": "mtdowling@gmail.com", "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" } ], - "description": "Guzzle is a PHP HTTP client library and framework for building RESTful web service clients", - "homepage": "http://guzzlephp.org/", + "description": "Guzzle is a PHP HTTP client library", "keywords": [ "client", "curl", "framework", "http", "http client", + "psr-18", + "psr-7", "rest", "web service" ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/5.3" + "source": "https://github.com/guzzle/guzzle/tree/7.9.2" }, - "time": "2019-10-30T09:32:00+00:00" + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2024-07-24T11:22:20+00:00" }, { - "name": "guzzlehttp/ringphp", - "version": "1.1.1", + "name": "guzzlehttp/promises", + "version": "2.0.4", "source": { "type": "git", - "url": "https://github.com/guzzle/RingPHP.git", - "reference": "5e2a174052995663dd68e6b5ad838afd47dd615b" + "url": "https://github.com/guzzle/promises.git", + "reference": "f9c436286ab2892c7db7be8c8da4ef61ccf7b455" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/RingPHP/zipball/5e2a174052995663dd68e6b5ad838afd47dd615b", - "reference": "5e2a174052995663dd68e6b5ad838afd47dd615b", + "url": "https://api.github.com/repos/guzzle/promises/zipball/f9c436286ab2892c7db7be8c8da4ef61ccf7b455", + "reference": "f9c436286ab2892c7db7be8c8da4ef61ccf7b455", "shasum": "" }, "require": { - "guzzlehttp/streams": "~3.0", - "php": ">=5.4.0", - "react/promise": "~2.0" + "php": "^7.2.5 || ^8.0" }, "require-dev": { - "ext-curl": "*", - "phpunit/phpunit": "~4.0" - }, - "suggest": { - "ext-curl": "Guzzle will use specific adapters if cURL is present" + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.39 || ^9.6.20" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "1.1-dev" + "bamarni-bin": { + "bin-links": true, + "forward-command": false } }, "autoload": { "psr-4": { - "GuzzleHttp\\Ring\\": "src/" + "GuzzleHttp\\Promise\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -586,49 +497,93 @@ "MIT" ], "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, { "name": "Michael Dowling", "email": "mtdowling@gmail.com", "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" } ], - "description": "Provides a simple API and specification that abstracts away the details of HTTP into a single PHP function.", + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], "support": { - "issues": "https://github.com/guzzle/RingPHP/issues", - "source": "https://github.com/guzzle/RingPHP/tree/1.1.1" + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.0.4" }, - "abandoned": true, - "time": "2018-07-31T13:22:33+00:00" + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2024-10-17T10:06:22+00:00" }, { - "name": "guzzlehttp/streams", - "version": "3.0.0", + "name": "guzzlehttp/psr7", + "version": "2.7.0", "source": { "type": "git", - "url": "https://github.com/guzzle/streams.git", - "reference": "47aaa48e27dae43d39fc1cea0ccf0d84ac1a2ba5" + "url": "https://github.com/guzzle/psr7.git", + "reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/streams/zipball/47aaa48e27dae43d39fc1cea0ccf0d84ac1a2ba5", - "reference": "47aaa48e27dae43d39fc1cea0ccf0d84ac1a2ba5", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/a70f5c95fb43bc83f07c9c948baa0dc1829bf201", + "reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201", "shasum": "" }, "require": { - "php": ">=5.4.0" + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" }, "require-dev": { - "phpunit/phpunit": "~4.0" + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.39 || ^9.6.20" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" }, "type": "library", "extra": { - "branch-alias": { - "dev-master": "3.0-dev" + "bamarni-bin": { + "bin-links": true, + "forward-command": false } }, "autoload": { "psr-4": { - "GuzzleHttp\\Stream\\": "src/" + "GuzzleHttp\\Psr7\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -636,24 +591,72 @@ "MIT" ], "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, { "name": "Michael Dowling", "email": "mtdowling@gmail.com", "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" } ], - "description": "Provides a simple abstraction over streams of data", - "homepage": "http://guzzlephp.org/", + "description": "PSR-7 message implementation that also provides common utility methods", "keywords": [ - "Guzzle", - "stream" + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" ], "support": { - "issues": "https://github.com/guzzle/streams/issues", - "source": "https://github.com/guzzle/streams/tree/master" + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.7.0" }, - "abandoned": true, - "time": "2014-10-12T19:18:40+00:00" + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2024-07-18T11:15:46+00:00" }, { "name": "khill/php-duration", @@ -717,6 +720,71 @@ }, "time": "2021-03-17T11:34:55+00:00" }, + { + "name": "league/oauth2-client", + "version": "2.8.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/oauth2-client.git", + "reference": "3d5cf8d0543731dfb725ab30e4d7289891991e13" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/oauth2-client/zipball/3d5cf8d0543731dfb725ab30e4d7289891991e13", + "reference": "3d5cf8d0543731dfb725ab30e4d7289891991e13", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", + "php": "^7.1 || >=8.0.0 <8.5.0" + }, + "require-dev": { + "mockery/mockery": "^1.3.5", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\OAuth2\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alex Bilbie", + "email": "hello@alexbilbie.com", + "homepage": "http://www.alexbilbie.com", + "role": "Developer" + }, + { + "name": "Woody Gilk", + "homepage": "https://github.com/shadowhand", + "role": "Contributor" + } + ], + "description": "OAuth 2.0 Client Library", + "keywords": [ + "Authentication", + "SSO", + "authorization", + "identity", + "idp", + "oauth", + "oauth2", + "single sign on" + ], + "support": { + "issues": "https://github.com/thephpleague/oauth2-client/issues", + "source": "https://github.com/thephpleague/oauth2-client/tree/2.8.0" + }, + "time": "2024-12-11T05:05:52+00:00" + }, { "name": "padraic/humbug_get_contents", "version": "dev-allow-php-8", @@ -851,33 +919,36 @@ "time": "2018-03-30T12:52:15+00:00" }, { - "name": "paragonie/random_compat", - "version": "v2.0.21", + "name": "platformsh/client", + "version": "3.0.0-beta1", "source": { "type": "git", - "url": "https://github.com/paragonie/random_compat.git", - "reference": "96c132c7f2f7bc3230723b66e89f8f150b29d5ae" + "url": "https://github.com/platformsh/platformsh-client-php.git", + "reference": "6d5e117f952a513a8673d29b83ef2c505cf5c693" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paragonie/random_compat/zipball/96c132c7f2f7bc3230723b66e89f8f150b29d5ae", - "reference": "96c132c7f2f7bc3230723b66e89f8f150b29d5ae", + "url": "https://api.github.com/repos/platformsh/platformsh-client-php/zipball/6d5e117f952a513a8673d29b83ef2c505cf5c693", + "reference": "6d5e117f952a513a8673d29b83ef2c505cf5c693", "shasum": "" }, "require": { - "php": ">=5.2.0" + "cocur/slugify": "^3.0", + "ext-json": "*", + "guzzlehttp/guzzle": "^7 || ^8", + "khill/php-duration": "^1.1", + "php": ">=8.2", + "platformsh/oauth2": "^1.0.0@beta" }, "require-dev": { - "phpunit/phpunit": "*" - }, - "suggest": { - "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." + "phpunit/phpunit": "^11", + "symplify/easy-coding-standard": "^12.3" }, "type": "library", "autoload": { - "files": [ - "lib/random.php" - ] + "psr-4": { + "Platformsh\\Client\\": "src" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -885,50 +956,42 @@ ], "authors": [ { - "name": "Paragon Initiative Enterprises", - "email": "security@paragonie.com", - "homepage": "https://paragonie.com" + "name": "Patrick Dawkins" } ], - "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", - "keywords": [ - "csprng", - "polyfill", - "pseudorandom", - "random" - ], + "description": "Platform.sh API client", "support": { - "email": "info@paragonie.com", - "issues": "https://github.com/paragonie/random_compat/issues", - "source": "https://github.com/paragonie/random_compat" + "issues": "https://github.com/platformsh/platformsh-client-php/issues", + "source": "https://github.com/platformsh/platformsh-client-php/tree/3.0.0-beta1" }, - "time": "2022-02-16T17:07:03+00:00" + "time": "2024-11-25T20:48:02+00:00" }, { - "name": "pjcdawkins/guzzle-oauth2-plugin", - "version": "v2.4.1", + "name": "platformsh/console-form", + "version": "v1.0.0-beta1", "source": { "type": "git", - "url": "https://github.com/pjcdawkins/guzzle-oauth2-plugin.git", - "reference": "affdff1cfe962c436b7e725607d03f971602c249" + "url": "https://github.com/platformsh/console-form.git", + "reference": "e7d95f5b04cd0d5a522107ae292865ee52d6ba6c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/pjcdawkins/guzzle-oauth2-plugin/zipball/affdff1cfe962c436b7e725607d03f971602c249", - "reference": "affdff1cfe962c436b7e725607d03f971602c249", + "url": "https://api.github.com/repos/platformsh/console-form/zipball/e7d95f5b04cd0d5a522107ae292865ee52d6ba6c", + "reference": "e7d95f5b04cd0d5a522107ae292865ee52d6ba6c", "shasum": "" }, "require": { - "firebase/php-jwt": "~2.0", - "guzzlehttp/guzzle": "~5.0" + "php": "^8.2", + "symfony/console": "^7.0 || ^6.0" }, "require-dev": { - "phpunit/phpunit": "~4.5" + "phpunit/phpunit": "^11", + "symplify/easy-coding-standard": "^12.3" }, "type": "library", "autoload": { "psr-4": { - "CommerceGuys\\Guzzle\\Oauth2\\": "src" + "Platformsh\\ConsoleForm\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -936,51 +999,43 @@ "MIT" ], "authors": [ - { - "name": "Bojan Zivanovic" - }, - { - "name": "Damien Tournoud" - }, { "name": "Patrick Dawkins" } ], - "description": "An OAuth2 plugin (subscriber) for Guzzle (forked from commerceguys/guzzle-oauth2-plugin)", + "description": "A lightweight Symfony Console form system.", "support": { - "source": "https://github.com/pjcdawkins/guzzle-oauth2-plugin/tree/v2.4.1" + "issues": "https://github.com/platformsh/console-form/issues", + "source": "https://github.com/platformsh/console-form/tree/v1.0.0-beta1" }, - "time": "2024-05-23T15:23:52+00:00" + "time": "2024-11-25T23:14:07+00:00" }, { - "name": "platformsh/client", - "version": "0.87.0", + "name": "platformsh/oauth2", + "version": "1.0.0-beta3", "source": { "type": "git", - "url": "https://github.com/platformsh/platformsh-client-php.git", - "reference": "3ba1dde15d3fb34568c87f8b6a84515646e9c880" + "url": "https://github.com/platformsh/platformsh-oauth2-php.git", + "reference": "3c0e12549850837a827ca432a50aa052e3cab250" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/platformsh/platformsh-client-php/zipball/3ba1dde15d3fb34568c87f8b6a84515646e9c880", - "reference": "3ba1dde15d3fb34568c87f8b6a84515646e9c880", + "url": "https://api.github.com/repos/platformsh/platformsh-oauth2-php/zipball/3c0e12549850837a827ca432a50aa052e3cab250", + "reference": "3c0e12549850837a827ca432a50aa052e3cab250", "shasum": "" }, "require": { - "cocur/slugify": "^2.0 || ~1.0", - "ext-json": "*", - "guzzlehttp/cache-subscriber": "~0.1", - "guzzlehttp/guzzle": "~5.3", - "php": ">=5.5.9", - "pjcdawkins/guzzle-oauth2-plugin": ">=2.4.0 <=3.0" + "league/oauth2-client": "^2.2", + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "~4.5" + "phpunit/phpunit": "^11", + "symplify/easy-coding-standard": "^12.3" }, "type": "library", "autoload": { "psr-4": { - "Platformsh\\Client\\": "src" + "Platformsh\\OAuth2\\Client\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -992,38 +1047,39 @@ "name": "Patrick Dawkins" } ], - "description": "Platform.sh API client", + "description": "Platform.sh OAuth2 client", "support": { - "issues": "https://github.com/platformsh/platformsh-client-php/issues", - "source": "https://github.com/platformsh/platformsh-client-php/tree/0.87.0" + "issues": "https://github.com/platformsh/platformsh-oauth2-php/issues", + "source": "https://github.com/platformsh/platformsh-oauth2-php/tree/1.0.0-beta3" }, - "time": "2024-11-14T08:02:39+00:00" + "time": "2024-11-25T19:19:41+00:00" }, { - "name": "platformsh/console-form", - "version": "v0.0.37", + "name": "psr/container", + "version": "2.0.2", "source": { "type": "git", - "url": "https://github.com/platformsh/console-form.git", - "reference": "f3c499c189d95191f5f92127a03d86f053928a40" + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/platformsh/console-form/zipball/f3c499c189d95191f5f92127a03d86f053928a40", - "reference": "f3c499c189d95191f5f92127a03d86f053928a40", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", "shasum": "" }, "require": { - "php": ">=5.5.9", - "symfony/console": "^6.0 || ^5.0 || ^4.0 || ^3.0 || ^2.6" - }, - "require-dev": { - "phpunit/phpunit": "^5.0" + "php": ">=7.4.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, "autoload": { "psr-4": { - "Platformsh\\ConsoleForm\\": "src" + "Psr\\Container\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -1032,37 +1088,51 @@ ], "authors": [ { - "name": "Patrick Dawkins" + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" } ], - "description": "A lightweight Symfony Console form system.", + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], "support": { - "issues": "https://github.com/platformsh/console-form/issues", - "source": "https://github.com/platformsh/console-form/tree/v0.0.37" + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" }, - "time": "2023-05-05T09:24:39+00:00" + "time": "2021-11-05T16:47:00+00:00" }, { - "name": "psr/container", - "version": "1.1.2", + "name": "psr/event-dispatcher", + "version": "1.0.0", "source": { "type": "git", - "url": "https://github.com/php-fig/container.git", - "reference": "513e0666f7216c7459170d56df27dfcefe1689ea" + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/513e0666f7216c7459170d56df27dfcefe1689ea", - "reference": "513e0666f7216c7459170d56df27dfcefe1689ea", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", "shasum": "" }, "require": { - "php": ">=7.4.0" + "php": ">=7.2.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, "autoload": { "psr-4": { - "Psr\\Container\\": "src/" + "Psr\\EventDispatcher\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -1072,50 +1142,48 @@ "authors": [ { "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" + "homepage": "http://www.php-fig.org/" } ], - "description": "Common Container Interface (PHP FIG PSR-11)", - "homepage": "https://github.com/php-fig/container", + "description": "Standard interfaces for event handling.", "keywords": [ - "PSR-11", - "container", - "container-interface", - "container-interop", - "psr" + "events", + "psr", + "psr-14" ], "support": { - "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/1.1.2" + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" }, - "time": "2021-11-05T16:50:12+00:00" + "time": "2019-01-08T18:20:26+00:00" }, { - "name": "psr/log", - "version": "1.1.4", + "name": "psr/http-client", + "version": "1.0.3", "source": { "type": "git", - "url": "https://github.com/php-fig/log.git", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.1.x-dev" + "dev-master": "1.0.x-dev" } }, "autoload": { "psr-4": { - "Psr\\Log\\": "Psr/Log/" + "Psr\\Http\\Client\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -1128,45 +1196,46 @@ "homepage": "https://www.php-fig.org/" } ], - "description": "Common interface for logging libraries", - "homepage": "https://github.com/php-fig/log", + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", "keywords": [ - "log", + "http", + "http-client", "psr", - "psr-3" + "psr-18" ], "support": { - "source": "https://github.com/php-fig/log/tree/1.1.4" + "source": "https://github.com/php-fig/http-client" }, - "time": "2021-05-03T11:20:27+00:00" + "time": "2023-09-23T14:17:50+00:00" }, { - "name": "react/promise", - "version": "v2.11.0", + "name": "psr/http-factory", + "version": "1.1.0", "source": { "type": "git", - "url": "https://github.com/reactphp/promise.git", - "reference": "1a8460931ea36dc5c76838fec5734d55c88c6831" + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/promise/zipball/1a8460931ea36dc5c76838fec5734d55c88c6831", - "reference": "1a8460931ea36dc5c76838fec5734d55c88c6831", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", "shasum": "" }, "require": { - "php": ">=5.4.0" - }, - "require-dev": { - "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, "autoload": { - "files": [ - "src/functions_include.php" - ], "psr-4": { - "React\\Promise\\": "src/" + "Psr\\Http\\Message\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -1175,74 +1244,105 @@ ], "authors": [ { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" } ], - "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", "keywords": [ - "promise", - "promises" + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" ], "support": { - "issues": "https://github.com/reactphp/promise/issues", - "source": "https://github.com/reactphp/promise/tree/v2.11.0" + "source": "https://github.com/php-fig/http-factory" }, - "funding": [ - { - "url": "https://opencollective.com/reactphp", - "type": "open_collective" - } - ], - "time": "2023-11-16T16:16:50+00:00" + "time": "2024-04-15T12:06:14+00:00" }, { - "name": "stecman/symfony-console-completion", - "version": "0.11.0", + "name": "psr/http-message", + "version": "2.0", "source": { "type": "git", - "url": "https://github.com/stecman/symfony-console-completion.git", - "reference": "a9502dab59405e275a9f264536c4e1cb61fc3518" + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/stecman/symfony-console-completion/zipball/a9502dab59405e275a9f264536c4e1cb61fc3518", - "reference": "a9502dab59405e275a9f264536c4e1cb61fc3518", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", "shasum": "" }, "require": { - "php": ">=5.3.2", - "symfony/console": "~2.3 || ~3.0 || ~4.0 || ~5.0" - }, - "require-dev": { - "phpunit/phpunit": "~4.8.36 || ~5.7 || ~6.4" + "php": "^7.2 || ^8.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "0.10.x-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { "psr-4": { - "Stecman\\Component\\Symfony\\Console\\BashCompletion\\": "src/" + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1250,48 +1350,47 @@ ], "authors": [ { - "name": "Stephen Holdaway", - "email": "stephen@stecman.co.nz" + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" } ], - "description": "Automatic BASH completion for Symfony Console Component based applications.", + "description": "A polyfill for getallheaders.", "support": { - "issues": "https://github.com/stecman/symfony-console-completion/issues", - "source": "https://github.com/stecman/symfony-console-completion/tree/0.11.0" + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" }, - "time": "2019-11-24T17:03:06+00:00" + "time": "2019-03-08T08:55:37+00:00" }, { "name": "symfony/config", - "version": "v3.4.47", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/config.git", - "reference": "bc6b3fd3930d4b53a60b42fe2ed6fc466b75f03f" + "reference": "bcd3c4adf0144dee5011bb35454728c38adec055" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/config/zipball/bc6b3fd3930d4b53a60b42fe2ed6fc466b75f03f", - "reference": "bc6b3fd3930d4b53a60b42fe2ed6fc466b75f03f", + "url": "https://api.github.com/repos/symfony/config/zipball/bcd3c4adf0144dee5011bb35454728c38adec055", + "reference": "bcd3c4adf0144dee5011bb35454728c38adec055", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", - "symfony/filesystem": "~2.8|~3.0|~4.0", + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/filesystem": "^7.1", "symfony/polyfill-ctype": "~1.8" }, "conflict": { - "symfony/dependency-injection": "<3.3", - "symfony/finder": "<3.3" + "symfony/finder": "<6.4", + "symfony/service-contracts": "<2.5" }, "require-dev": { - "symfony/dependency-injection": "~3.3|~4.0", - "symfony/event-dispatcher": "~3.3|~4.0", - "symfony/finder": "~3.3|~4.0", - "symfony/yaml": "~3.0|~4.0" - }, - "suggest": { - "symfony/yaml": "To use the yaml reference dumper" + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/finder": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -1316,10 +1415,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony Config Component", + "description": "Helps you find, load, combine, autofill and validate configuration values of any kind", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/config/tree/v3.4.47" + "source": "https://github.com/symfony/config/tree/v7.2.0" }, "funding": [ { @@ -1335,47 +1434,50 @@ "type": "tidelift" } ], - "time": "2020-10-24T10:57:07+00:00" + "time": "2024-11-04T11:36:24+00:00" }, { "name": "symfony/console", - "version": "v3.4.47", + "version": "v7.2.1", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "a10b1da6fc93080c180bba7219b5ff5b7518fe81" + "reference": "fefcc18c0f5d0efe3ab3152f15857298868dc2c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/a10b1da6fc93080c180bba7219b5ff5b7518fe81", - "reference": "a10b1da6fc93080c180bba7219b5ff5b7518fe81", + "url": "https://api.github.com/repos/symfony/console/zipball/fefcc18c0f5d0efe3ab3152f15857298868dc2c3", + "reference": "fefcc18c0f5d0efe3ab3152f15857298868dc2c3", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", - "symfony/debug": "~2.8|~3.0|~4.0", - "symfony/polyfill-mbstring": "~1.0" + "php": ">=8.2", + "symfony/polyfill-mbstring": "~1.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^6.4|^7.0" }, "conflict": { - "symfony/dependency-injection": "<3.4", - "symfony/process": "<3.3" + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" }, "provide": { - "psr/log-implementation": "1.0" + "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { - "psr/log": "~1.0", - "symfony/config": "~3.3|~4.0", - "symfony/dependency-injection": "~3.4|~4.0", - "symfony/event-dispatcher": "~2.8|~3.0|~4.0", - "symfony/lock": "~3.4|~4.0", - "symfony/process": "~3.3|~4.0" - }, - "suggest": { - "psr/log": "For using the console logger", - "symfony/event-dispatcher": "", - "symfony/lock": "", - "symfony/process": "" + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/lock": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -1400,10 +1502,16 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony Console Component", + "description": "Eases the creation of beautiful and testable command line interfaces", "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], "support": { - "source": "https://github.com/symfony/console/tree/v3.4.47" + "source": "https://github.com/symfony/console/tree/v7.2.1" }, "funding": [ { @@ -1419,36 +1527,48 @@ "type": "tidelift" } ], - "time": "2020-10-24T10:57:07+00:00" + "time": "2024-12-11T03:49:26+00:00" }, { - "name": "symfony/debug", - "version": "v4.4.44", + "name": "symfony/dependency-injection", + "version": "v7.2.0", "source": { "type": "git", - "url": "https://github.com/symfony/debug.git", - "reference": "1a692492190773c5310bc7877cb590c04c2f05be" + "url": "https://github.com/symfony/dependency-injection.git", + "reference": "a475747af1a1c98272a5471abc35f3da81197c5d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/debug/zipball/1a692492190773c5310bc7877cb590c04c2f05be", - "reference": "1a692492190773c5310bc7877cb590c04c2f05be", + "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/a475747af1a1c98272a5471abc35f3da81197c5d", + "reference": "a475747af1a1c98272a5471abc35f3da81197c5d", "shasum": "" }, "require": { - "php": ">=7.1.3", - "psr/log": "^1|^2|^3" + "php": ">=8.2", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/service-contracts": "^3.5", + "symfony/var-exporter": "^6.4|^7.0" }, "conflict": { - "symfony/http-kernel": "<3.4" + "ext-psr": "<1.1|>=2", + "symfony/config": "<6.4", + "symfony/finder": "<6.4", + "symfony/yaml": "<6.4" + }, + "provide": { + "psr/container-implementation": "1.1|2.0", + "symfony/service-implementation": "1.1|2.0|3.0" }, "require-dev": { - "symfony/http-kernel": "^3.4|^4.0|^5.0" + "symfony/config": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Debug\\": "" + "Symfony\\Component\\DependencyInjection\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -1468,10 +1588,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Provides tools to ease debugging PHP code", + "description": "Allows you to standardize and centralize the way objects are constructed in your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/debug/tree/v4.4.44" + "source": "https://github.com/symfony/dependency-injection/tree/v7.2.0" }, "funding": [ { @@ -1487,55 +1607,38 @@ "type": "tidelift" } ], - "abandoned": "symfony/error-handler", - "time": "2022-07-28T16:29:46+00:00" + "time": "2024-11-25T15:45:00+00:00" }, { - "name": "symfony/dependency-injection", - "version": "v3.4.47", + "name": "symfony/deprecation-contracts", + "version": "v3.5.1", "source": { "type": "git", - "url": "https://github.com/symfony/dependency-injection.git", - "reference": "51d2a2708c6ceadad84393f8581df1dcf9e5e84b" + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/dependency-injection/zipball/51d2a2708c6ceadad84393f8581df1dcf9e5e84b", - "reference": "51d2a2708c6ceadad84393f8581df1dcf9e5e84b", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", + "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", - "psr/container": "^1.0" - }, - "conflict": { - "symfony/config": "<3.3.7", - "symfony/finder": "<3.3", - "symfony/proxy-manager-bridge": "<3.4", - "symfony/yaml": "<3.4" - }, - "provide": { - "psr/container-implementation": "1.0" - }, - "require-dev": { - "symfony/config": "~3.3|~4.0", - "symfony/expression-language": "~2.8|~3.0|~4.0", - "symfony/yaml": "~3.4|~4.0" - }, - "suggest": { - "symfony/config": "", - "symfony/expression-language": "For using expressions in service container configuration", - "symfony/finder": "For using double-star glob patterns or when GLOB_BRACE portability is required", - "symfony/proxy-manager-bridge": "Generate service proxies to lazy load them", - "symfony/yaml": "" + "php": ">=8.1" }, "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\DependencyInjection\\": "" + "extra": { + "branch-alias": { + "dev-main": "3.5-dev" }, - "exclude-from-classmap": [ - "/Tests/" + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "files": [ + "function.php" ] }, "notification-url": "https://packagist.org/downloads/", @@ -1544,18 +1647,18 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony DependencyInjection Component", + "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/dependency-injection/tree/v3.4.47" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1" }, "funding": [ { @@ -1571,39 +1674,43 @@ "type": "tidelift" } ], - "time": "2020-10-24T10:57:07+00:00" + "time": "2024-09-25T14:20:29+00:00" }, { "name": "symfony/event-dispatcher", - "version": "v3.4.47", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "31fde73757b6bad247c54597beef974919ec6860" + "reference": "910c5db85a5356d0fea57680defec4e99eb9c8c1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/31fde73757b6bad247c54597beef974919ec6860", - "reference": "31fde73757b6bad247c54597beef974919ec6860", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/910c5db85a5356d0fea57680defec4e99eb9c8c1", + "reference": "910c5db85a5356d0fea57680defec4e99eb9c8c1", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": ">=8.2", + "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { - "symfony/dependency-injection": "<3.3" + "symfony/dependency-injection": "<6.4", + "symfony/service-contracts": "<2.5" }, - "require-dev": { - "psr/log": "~1.0", - "symfony/config": "~2.8|~3.0|~4.0", - "symfony/debug": "~3.4|~4.4", - "symfony/dependency-injection": "~3.3|~4.0", - "symfony/expression-language": "~2.8|~3.0|~4.0", - "symfony/stopwatch": "~2.8|~3.0|~4.0" + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" }, - "suggest": { - "symfony/dependency-injection": "", - "symfony/http-kernel": "" + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/error-handler": "^6.4|^7.0", + "symfony/expression-language": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -1628,10 +1735,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony EventDispatcher Component", + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v3.4.47" + "source": "https://github.com/symfony/event-dispatcher/tree/v7.2.0" }, "funding": [ { @@ -1647,34 +1754,40 @@ "type": "tidelift" } ], - "time": "2020-10-24T10:57:07+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { - "name": "symfony/filesystem", - "version": "v3.4.47", + "name": "symfony/event-dispatcher-contracts", + "version": "v3.5.1", "source": { "type": "git", - "url": "https://github.com/symfony/filesystem.git", - "reference": "e58d7841cddfed6e846829040dca2cca0ebbbbb3" + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/e58d7841cddfed6e846829040dca2cca0ebbbbb3", - "reference": "e58d7841cddfed6e846829040dca2cca0ebbbbb3", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/7642f5e970b672283b7823222ae8ef8bbc160b9f", + "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", - "symfony/polyfill-ctype": "~1.8" + "php": ">=8.1", + "psr/event-dispatcher": "^1" }, "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.5-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, "autoload": { "psr-4": { - "Symfony\\Component\\Filesystem\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\Contracts\\EventDispatcher\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1682,18 +1795,26 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony Filesystem Component", + "description": "Generic abstractions related to dispatching event", "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], "support": { - "source": "https://github.com/symfony/filesystem/tree/v3.4.47" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.1" }, "funding": [ { @@ -1709,29 +1830,34 @@ "type": "tidelift" } ], - "time": "2020-10-24T10:57:07+00:00" + "time": "2024-09-25T14:20:29+00:00" }, { - "name": "symfony/finder", - "version": "v3.4.47", + "name": "symfony/filesystem", + "version": "v7.2.0", "source": { "type": "git", - "url": "https://github.com/symfony/finder.git", - "reference": "b6b6ad3db3edb1b4b1c1896b1975fb684994de6e" + "url": "https://github.com/symfony/filesystem.git", + "reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/b6b6ad3db3edb1b4b1c1896b1975fb684994de6e", - "reference": "b6b6ad3db3edb1b4b1c1896b1975fb684994de6e", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/b8dce482de9d7c9fe2891155035a7248ab5c7fdb", + "reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^6.4|^7.0" }, "type": "library", "autoload": { "psr-4": { - "Symfony\\Component\\Finder\\": "" + "Symfony\\Component\\Filesystem\\": "" }, "exclude-from-classmap": [ "/Tests/" @@ -1751,10 +1877,10 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony Finder Component", + "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v3.4.47" + "source": "https://github.com/symfony/filesystem/tree/v7.2.0" }, "funding": [ { @@ -1770,49 +1896,113 @@ "type": "tidelift" } ], - "time": "2020-11-16T17:02:08+00:00" + "time": "2024-10-25T15:15:23+00:00" }, { - "name": "symfony/polyfill-ctype", - "version": "v1.31.0", + "name": "symfony/finder", + "version": "v7.2.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + "url": "https://github.com/symfony/finder.git", + "reference": "6de263e5868b9a137602dd1e33e4d48bfae99c49" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", - "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "url": "https://api.github.com/repos/symfony/finder/zipball/6de263e5868b9a137602dd1e33e4d48bfae99c49", + "reference": "6de263e5868b9a137602dd1e33e4d48bfae99c49", "shasum": "" }, "require": { - "php": ">=7.2" - }, - "provide": { - "ext-ctype": "*" + "php": ">=8.2" }, - "suggest": { - "ext-ctype": "For best performance" + "require-dev": { + "symfony/filesystem": "^6.4|^7.0" }, "type": "library", - "extra": { - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, "autoload": { - "files": [ - "bootstrap.php" - ], "psr-4": { - "Symfony\\Polyfill\\Ctype\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v7.2.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-10-23T06:56:12+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.31.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/a3cc8b044a6ea513310cbd48ef7333b384945638", + "reference": "a3cc8b044a6ea513310cbd48ef7333b384945638", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" ], "authors": [ { @@ -1932,33 +2122,30 @@ "time": "2024-09-09T11:45:10+00:00" }, { - "name": "symfony/polyfill-mbstring", + "name": "symfony/polyfill-intl-grapheme", "version": "v1.31.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", "shasum": "" }, "require": { "php": ">=7.2" }, - "provide": { - "ext-mbstring": "*" - }, "suggest": { - "ext-mbstring": "For best performance" + "ext-intl": "For best performance" }, "type": "library", "extra": { "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { @@ -1966,7 +2153,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -1983,17 +2170,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for the Mbstring extension", + "description": "Symfony polyfill for intl's grapheme_* functions", "homepage": "https://symfony.com", "keywords": [ "compatibility", - "mbstring", + "grapheme", + "intl", "polyfill", "portable", "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" }, "funding": [ { @@ -2012,29 +2200,41 @@ "time": "2024-09-09T11:45:10+00:00" }, { - "name": "symfony/process", - "version": "v3.4.47", + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.31.0", "source": { "type": "git", - "url": "https://github.com/symfony/process.git", - "reference": "b8648cf1d5af12a44a51d07ef9bf980921f15fca" + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/b8648cf1d5af12a44a51d07ef9bf980921f15fca", - "reference": "b8648cf1d5af12a44a51d07ef9bf980921f15fca", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8" + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Component\\Process\\": "" + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" }, - "exclude-from-classmap": [ - "/Tests/" + "classmap": [ + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", @@ -2043,18 +2243,26 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony Process Component", + "description": "Symfony polyfill for intl's Normalizer class and related functions", "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], "support": { - "source": "https://github.com/symfony/process/tree/v3.4.47" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" }, "funding": [ { @@ -2070,43 +2278,45 @@ "type": "tidelift" } ], - "time": "2020-10-24T10:57:07+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { - "name": "symfony/yaml", - "version": "v3.4.47", + "name": "symfony/polyfill-mbstring", + "version": "v1.31.0", "source": { "type": "git", - "url": "https://github.com/symfony/yaml.git", - "reference": "88289caa3c166321883f67fe5130188ebbb47094" + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/88289caa3c166321883f67fe5130188ebbb47094", - "reference": "88289caa3c166321883f67fe5130188ebbb47094", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", "shasum": "" }, "require": { - "php": "^5.5.9|>=7.0.8", - "symfony/polyfill-ctype": "~1.8" - }, - "conflict": { - "symfony/console": "<3.4" + "php": ">=7.2" }, - "require-dev": { - "symfony/console": "~3.4|~4.0" + "provide": { + "ext-mbstring": "*" }, "suggest": { - "symfony/console": "For validating YAML files using the lint command" + "ext-mbstring": "For best performance" }, "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, "autoload": { + "files": [ + "bootstrap.php" + ], "psr-4": { - "Symfony\\Component\\Yaml\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] + "Symfony\\Polyfill\\Mbstring\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2114,18 +2324,25 @@ ], "authors": [ { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { "name": "Symfony Community", "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony Yaml Component", + "description": "Symfony polyfill for the Mbstring extension", "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], "support": { - "source": "https://github.com/symfony/yaml/tree/v3.4.47" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" }, "funding": [ { @@ -2141,336 +2358,2259 @@ "type": "tidelift" } ], - "time": "2020-10-24T10:57:07+00:00" - } - ], - "packages-dev": [ + "time": "2024-09-09T11:45:10+00:00" + }, { - "name": "myclabs/deep-copy", - "version": "1.12.1", + "name": "symfony/process", + "version": "v7.2.0", "source": { "type": "git", - "url": "https://github.com/myclabs/DeepCopy.git", - "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845" + "url": "https://github.com/symfony/process.git", + "reference": "d34b22ba9390ec19d2dd966c40aa9e8462f27a7e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/123267b2c49fbf30d78a7b2d333f6be754b94845", - "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845", + "url": "https://api.github.com/repos/symfony/process/zipball/d34b22ba9390ec19d2dd966c40aa9e8462f27a7e", + "reference": "d34b22ba9390ec19d2dd966c40aa9e8462f27a7e", "shasum": "" }, "require": { - "php": "^7.1 || ^8.0" - }, - "conflict": { - "doctrine/collections": "<1.6.8", - "doctrine/common": "<2.13.3 || >=3 <3.2.2" - }, - "require-dev": { - "doctrine/collections": "^1.6.8", - "doctrine/common": "^2.13.3 || ^3.2.2", - "phpspec/prophecy": "^1.10", - "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + "php": ">=8.2" }, "type": "library", "autoload": { - "files": [ - "src/DeepCopy/deep_copy.php" - ], "psr-4": { - "DeepCopy\\": "src/DeepCopy/" - } + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "Create deep copies (clones) of your objects", - "keywords": [ - "clone", - "copy", - "duplicate", - "object", - "object graph" + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", "support": { - "issues": "https://github.com/myclabs/DeepCopy/issues", - "source": "https://github.com/myclabs/DeepCopy/tree/1.12.1" + "source": "https://github.com/symfony/process/tree/v7.2.0" }, "funding": [ { - "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", "type": "tidelift" } ], - "time": "2024-11-08T17:47:46+00:00" + "time": "2024-11-06T14:24:19+00:00" }, { - "name": "nikic/php-parser", - "version": "v5.3.1", + "name": "symfony/service-contracts", + "version": "v3.5.1", "source": { "type": "git", - "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b" + "url": "https://github.com/symfony/service-contracts.git", + "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/8eea230464783aa9671db8eea6f8c6ac5285794b", - "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/e53260aabf78fb3d63f8d79d69ece59f80d5eda0", + "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0", "shasum": "" }, "require": { - "ext-ctype": "*", - "ext-json": "*", - "ext-tokenizer": "*", - "php": ">=7.4" + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" }, - "require-dev": { - "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^9.0" + "conflict": { + "ext-psr": "<1.1|>=2" }, - "bin": [ - "bin/php-parse" - ], "type": "library", "extra": { "branch-alias": { - "dev-master": "5.0-dev" + "dev-main": "3.5-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" } }, "autoload": { "psr-4": { - "PhpParser\\": "lib/PhpParser" - } + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Nikita Popov" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.5.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:20:29+00:00" + }, + { + "name": "symfony/string", + "version": "v7.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/446e0d146f991dde3e73f45f2c97a9faad773c82", + "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-grapheme": "~1.0", + "symfony/polyfill-intl-normalizer": "~1.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.1", + "symfony/error-handler": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v7.2.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-11-13T13:31:26+00:00" + }, + { + "name": "symfony/var-exporter", + "version": "v7.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-exporter.git", + "reference": "1a6a89f95a46af0f142874c9d650a6358d13070d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-exporter/zipball/1a6a89f95a46af0f142874c9d650a6358d13070d", + "reference": "1a6a89f95a46af0f142874c9d650a6358d13070d", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "symfony/property-access": "^6.4|^7.0", + "symfony/serializer": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\VarExporter\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows exporting any serializable PHP data structure to plain PHP code", + "homepage": "https://symfony.com", + "keywords": [ + "clone", + "construct", + "export", + "hydrate", + "instantiate", + "lazy-loading", + "proxy", + "serialize" + ], + "support": { + "source": "https://github.com/symfony/var-exporter/tree/v7.2.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-10-18T07:58:17+00:00" + }, + { + "name": "symfony/yaml", + "version": "v7.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "099581e99f557e9f16b43c5916c26380b54abb22" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/099581e99f557e9f16b43c5916c26380b54abb22", + "reference": "099581e99f557e9f16b43c5916c26380b54abb22", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<6.4" + }, + "require-dev": { + "symfony/console": "^6.4|^7.0" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v7.2.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-10-23T06:56:12+00:00" + } + ], + "packages-dev": [ + { + "name": "clue/ndjson-react", + "version": "v1.3.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-ndjson.git", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0", + "reference": "392dc165fce93b5bb5c637b67e59619223c931b0", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/stream": "^1.2" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35", + "react/event-loop": "^1.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\NDJson\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.", + "homepage": "https://github.com/clue/reactphp-ndjson", + "keywords": [ + "NDJSON", + "json", + "jsonlines", + "newline", + "reactphp", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/reactphp-ndjson/issues", + "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2022-12-23T10:58:28+00:00" + }, + { + "name": "composer/pcre", + "version": "3.3.2", + "source": { + "type": "git", + "url": "https://github.com/composer/pcre.git", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "conflict": { + "phpstan/phpstan": "<1.11.10" + }, + "require-dev": { + "phpstan/phpstan": "^1.12 || ^2", + "phpstan/phpstan-strict-rules": "^1 || ^2", + "phpunit/phpunit": "^8 || ^9" + }, + "type": "library", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Pcre\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + } + ], + "description": "PCRE wrapping library that offers type-safe preg_* replacements.", + "keywords": [ + "PCRE", + "preg", + "regex", + "regular expression" + ], + "support": { + "issues": "https://github.com/composer/pcre/issues", + "source": "https://github.com/composer/pcre/tree/3.3.2" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-11-12T16:29:46+00:00" + }, + { + "name": "composer/semver", + "version": "3.4.3", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", + "reference": "4313d26ada5e0c4edfbd1dc481a92ff7bff91f12", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.3" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-09-19T14:15:21+00:00" + }, + { + "name": "composer/xdebug-handler", + "version": "3.0.5", + "source": { + "type": "git", + "url": "https://github.com/composer/xdebug-handler.git", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef", + "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef", + "shasum": "" + }, + "require": { + "composer/pcre": "^1 || ^2 || ^3", + "php": "^7.2.5 || ^8.0", + "psr/log": "^1 || ^2 || ^3" + }, + "require-dev": { + "phpstan/phpstan": "^1.0", + "phpstan/phpstan-strict-rules": "^1.1", + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Composer\\XdebugHandler\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "John Stevenson", + "email": "john-stevenson@blueyonder.co.uk" + } + ], + "description": "Restarts a process without Xdebug.", + "keywords": [ + "Xdebug", + "performance" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/xdebug-handler/issues", + "source": "https://github.com/composer/xdebug-handler/tree/3.0.5" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/composer/composer", + "type": "tidelift" + } + ], + "time": "2024-05-06T16:37:16+00:00" + }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, + { + "name": "fidry/cpu-core-counter", + "version": "1.2.0", + "source": { + "type": "git", + "url": "https://github.com/theofidry/cpu-core-counter.git", + "reference": "8520451a140d3f46ac33042715115e290cf5785f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/8520451a140d3f46ac33042715115e290cf5785f", + "reference": "8520451a140d3f46ac33042715115e290cf5785f", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "fidry/makefile": "^0.2.0", + "fidry/php-cs-fixer-config": "^1.1.2", + "phpstan/extension-installer": "^1.2.0", + "phpstan/phpstan": "^1.9.2", + "phpstan/phpstan-deprecation-rules": "^1.0.0", + "phpstan/phpstan-phpunit": "^1.2.2", + "phpstan/phpstan-strict-rules": "^1.4.4", + "phpunit/phpunit": "^8.5.31 || ^9.5.26", + "webmozarts/strict-phpunit": "^7.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Fidry\\CpuCoreCounter\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Théo FIDRY", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Tiny utility to get the number of CPU cores.", + "keywords": [ + "CPU", + "core" + ], + "support": { + "issues": "https://github.com/theofidry/cpu-core-counter/issues", + "source": "https://github.com/theofidry/cpu-core-counter/tree/1.2.0" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2024-08-06T10:04:20+00:00" + }, + { + "name": "friendsofphp/php-cs-fixer", + "version": "v3.65.0", + "source": { + "type": "git", + "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", + "reference": "79d4f3e77b250a7d8043d76c6af8f0695e8a469f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/79d4f3e77b250a7d8043d76c6af8f0695e8a469f", + "reference": "79d4f3e77b250a7d8043d76c6af8f0695e8a469f", + "shasum": "" + }, + "require": { + "clue/ndjson-react": "^1.0", + "composer/semver": "^3.4", + "composer/xdebug-handler": "^3.0.3", + "ext-filter": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "fidry/cpu-core-counter": "^1.2", + "php": "^7.4 || ^8.0", + "react/child-process": "^0.6.5", + "react/event-loop": "^1.0", + "react/promise": "^2.0 || ^3.0", + "react/socket": "^1.0", + "react/stream": "^1.0", + "sebastian/diff": "^4.0 || ^5.0 || ^6.0", + "symfony/console": "^5.4 || ^6.0 || ^7.0", + "symfony/event-dispatcher": "^5.4 || ^6.0 || ^7.0", + "symfony/filesystem": "^5.4 || ^6.0 || ^7.0", + "symfony/finder": "^5.4 || ^6.0 || ^7.0", + "symfony/options-resolver": "^5.4 || ^6.0 || ^7.0", + "symfony/polyfill-mbstring": "^1.28", + "symfony/polyfill-php80": "^1.28", + "symfony/polyfill-php81": "^1.28", + "symfony/process": "^5.4 || ^6.0 || ^7.0", + "symfony/stopwatch": "^5.4 || ^6.0 || ^7.0" + }, + "require-dev": { + "facile-it/paraunit": "^1.3.1 || ^2.4", + "infection/infection": "^0.29.8", + "justinrainbow/json-schema": "^5.3 || ^6.0", + "keradus/cli-executor": "^2.1", + "mikey179/vfsstream": "^1.6.12", + "php-coveralls/php-coveralls": "^2.7", + "php-cs-fixer/accessible-object": "^1.1", + "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.5", + "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.5", + "phpunit/phpunit": "^9.6.21 || ^10.5.38 || ^11.4.3", + "symfony/var-dumper": "^5.4.47 || ^6.4.15 || ^7.1.8", + "symfony/yaml": "^5.4.45 || ^6.4.13 || ^7.1.6" + }, + "suggest": { + "ext-dom": "For handling output formats in XML", + "ext-mbstring": "For handling non-UTF8 characters." + }, + "bin": [ + "php-cs-fixer" + ], + "type": "application", + "autoload": { + "psr-4": { + "PhpCsFixer\\": "src/" + }, + "exclude-from-classmap": [ + "src/Fixer/Internal/*" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Dariusz Rumiński", + "email": "dariusz.ruminski@gmail.com" + } + ], + "description": "A tool to automatically fix PHP code style", + "keywords": [ + "Static code analysis", + "fixer", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.65.0" + }, + "funding": [ + { + "url": "https://github.com/keradus", + "type": "github" + } + ], + "time": "2024-11-25T00:39:24+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.12.1", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/123267b2c49fbf30d78a7b2d333f6be754b94845", + "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.12.1" + }, + "funding": [ + { + "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", + "type": "tidelift" + } + ], + "time": "2024-11-08T17:47:46+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.3.1", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/8eea230464783aa9671db8eea6f8c6ac5285794b", + "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.1" + }, + "time": "2024-10-08T18:51:32+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpstan/phpstan", + "version": "1.12.13", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan.git", + "reference": "9b469068840cfa031e1deaf2fa1886d00e20680f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9b469068840cfa031e1deaf2fa1886d00e20680f", + "reference": "9b469068840cfa031e1deaf2fa1886d00e20680f", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2024-12-17T17:00:20+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "11.0.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "418c59fd080954f8c4aa5631d9502ecda2387118" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/418c59fd080954f8c4aa5631d9502ecda2387118", + "reference": "418c59fd080954f8c4aa5631d9502ecda2387118", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.3.1", + "php": ">=8.2", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-text-template": "^4.0.1", + "sebastian/code-unit-reverse-lookup": "^4.0.1", + "sebastian/complexity": "^4.0.1", + "sebastian/environment": "^7.2.0", + "sebastian/lines-of-code": "^3.0.1", + "sebastian/version": "^5.0.2", + "theseer/tokenizer": "^1.2.3" + }, + "require-dev": { + "phpunit/phpunit": "^11.5.0" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-12-11T12:34:27+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "5.1.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", + "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-08-27T05:02:59+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "5.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", + "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:07:44+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:08:43+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "shasum": "" + }, + "require": { + "php": ">=8.2" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-07-03T05:09:35+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "11.5.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "2b94d4f2450b9869fa64a46fd8a6a41997aef56a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/2b94d4f2450b9869fa64a46fd8a6a41997aef56a", + "reference": "2b94d4f2450b9869fa64a46fd8a6a41997aef56a", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xml": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.12.1", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.2", + "phpunit/php-code-coverage": "^11.0.7", + "phpunit/php-file-iterator": "^5.1.0", + "phpunit/php-invoker": "^5.0.1", + "phpunit/php-text-template": "^4.0.1", + "phpunit/php-timer": "^7.0.1", + "sebastian/cli-parser": "^3.0.2", + "sebastian/code-unit": "^3.0.1", + "sebastian/comparator": "^6.2.1", + "sebastian/diff": "^6.0.2", + "sebastian/environment": "^7.2.0", + "sebastian/exporter": "^6.3.0", + "sebastian/global-state": "^7.0.2", + "sebastian/object-enumerator": "^6.0.1", + "sebastian/type": "^5.1.0", + "sebastian/version": "^5.0.2", + "staabm/side-effects-detector": "^1.0.5" + }, + "suggest": { + "ext-soap": "To be able to generate mocks based on WSDL files" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "11.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.1" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsors.html", + "type": "custom" + }, + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", + "type": "tidelift" + } + ], + "time": "2024-12-11T10:52:48+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/child-process", + "version": "v0.6.5", + "source": { + "type": "git", + "url": "https://github.com/reactphp/child-process.git", + "reference": "e71eb1aa55f057c7a4a0d08d06b0b0a484bead43" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/child-process/zipball/e71eb1aa55f057c7a4a0d08d06b0b0a484bead43", + "reference": "e71eb1aa55f057c7a4a0d08d06b0b0a484bead43", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/event-loop": "^1.2", + "react/stream": "^1.2" + }, + "require-dev": { + "phpunit/phpunit": "^9.3 || ^5.7 || ^4.8.35", + "react/socket": "^1.8", + "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\ChildProcess\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven library for executing child processes with ReactPHP.", + "keywords": [ + "event-driven", + "process", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/child-process/issues", + "source": "https://github.com/reactphp/child-process/tree/v0.6.5" + }, + "funding": [ + { + "url": "https://github.com/WyriHaximus", + "type": "github" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2022-09-16T13:41:56+00:00" + }, + { + "name": "react/dns", + "version": "v1.13.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/eb8ae001b5a455665c89c1df97f6fb682f8fb0f5", + "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.13.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-13T14:18:03+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.5.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", + "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.5.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2023-11-13T13:48:05+00:00" + }, + { + "name": "react/promise", + "version": "v3.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "8a164643313c71354582dc850b42b33fa12a4b63" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/8a164643313c71354582dc850b42b33fa12a4b63", + "reference": "8a164643313c71354582dc850b42b33fa12a4b63", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.10.39 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" } ], - "description": "A PHP parser written in PHP", + "description": "A lightweight implementation of CommonJS Promises/A for PHP", "keywords": [ - "parser", - "php" + "promise", + "promises" ], "support": { - "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.1" + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.2.0" }, - "time": "2024-10-08T18:51:32+00:00" + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-05-24T10:39:05+00:00" }, { - "name": "phar-io/manifest", - "version": "2.0.4", + "name": "react/socket", + "version": "v1.16.0", "source": { "type": "git", - "url": "https://github.com/phar-io/manifest.git", - "reference": "54750ef60c58e43759730615a392c31c80e23176" + "url": "https://github.com/reactphp/socket.git", + "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", - "reference": "54750ef60c58e43759730615a392c31c80e23176", + "url": "https://api.github.com/repos/reactphp/socket/zipball/23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1", + "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-libxml": "*", - "ext-phar": "*", - "ext-xmlwriter": "*", - "phar-io/version": "^3.0.1", - "php": "^7.2 || ^8.0" + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" }, + "type": "library", "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "React\\Socket\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" }, { - "name": "Sebastian Heuer", - "email": "sebastian@phpeople.de", - "role": "Developer" + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" }, { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "Developer" + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" } ], - "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], "support": { - "issues": "https://github.com/phar-io/manifest/issues", - "source": "https://github.com/phar-io/manifest/tree/2.0.4" + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.16.0" }, "funding": [ { - "url": "https://github.com/theseer", - "type": "github" + "url": "https://opencollective.com/reactphp", + "type": "open_collective" } ], - "time": "2024-03-03T12:33:53+00:00" + "time": "2024-07-26T10:38:09+00:00" }, { - "name": "phar-io/version", - "version": "3.2.1", + "name": "react/stream", + "version": "v1.4.0", "source": { "type": "git", - "url": "https://github.com/phar-io/version.git", - "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", - "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" }, "type": "library", "autoload": { - "classmap": [ - "src/" - ] + "psr-4": { + "React\\Stream\\": "src/" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Arne Blankerts", - "email": "arne@blankerts.de", - "role": "Developer" + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" }, { - "name": "Sebastian Heuer", - "email": "sebastian@phpeople.de", - "role": "Developer" + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" }, { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "Developer" + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" } ], - "description": "Library for handling version information and constraints", + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], "support": { - "issues": "https://github.com/phar-io/version/issues", - "source": "https://github.com/phar-io/version/tree/3.2.1" + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" }, - "time": "2022-02-21T01:04:05+00:00" + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" }, { - "name": "phpunit/php-code-coverage", - "version": "11.0.7", + "name": "rector/rector", + "version": "1.2.10", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-code-coverage.git", - "reference": "f7f08030e8811582cc459871d28d6f5a1a4d35ca" + "url": "https://github.com/rectorphp/rector.git", + "reference": "40f9cf38c05296bd32f444121336a521a293fa61" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/f7f08030e8811582cc459871d28d6f5a1a4d35ca", - "reference": "f7f08030e8811582cc459871d28d6f5a1a4d35ca", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/40f9cf38c05296bd32f444121336a521a293fa61", + "reference": "40f9cf38c05296bd32f444121336a521a293fa61", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-libxml": "*", - "ext-xmlwriter": "*", - "nikic/php-parser": "^5.3.1", - "php": ">=8.2", - "phpunit/php-file-iterator": "^5.1.0", - "phpunit/php-text-template": "^4.0.1", - "sebastian/code-unit-reverse-lookup": "^4.0.1", - "sebastian/complexity": "^4.0.1", - "sebastian/environment": "^7.2.0", - "sebastian/lines-of-code": "^3.0.1", - "sebastian/version": "^5.0.2", - "theseer/tokenizer": "^1.2.3" + "php": "^7.2|^8.0", + "phpstan/phpstan": "^1.12.5" }, - "require-dev": { - "phpunit/phpunit": "^11.4.1" + "conflict": { + "rector/rector-doctrine": "*", + "rector/rector-downgrade-php": "*", + "rector/rector-phpunit": "*", + "rector/rector-symfony": "*" }, "suggest": { - "ext-pcov": "PHP extension that provides line coverage", - "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + "ext-dom": "To manipulate phpunit.xml via the custom-rule command" }, + "bin": [ + "bin/rector" + ], "type": "library", - "extra": { - "branch-alias": { - "dev-main": "11.0.x-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "files": [ + "bootstrap.php" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" - ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" - } + "MIT" ], - "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", - "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "description": "Instant Upgrade and Automated Refactoring of any PHP code", "keywords": [ - "coverage", - "testing", - "xunit" + "automation", + "dev", + "migration", + "refactoring" ], "support": { - "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", - "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", - "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.7" + "issues": "https://github.com/rectorphp/rector/issues", + "source": "https://github.com/rectorphp/rector/tree/1.2.10" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/tomasvotruba", "type": "github" } ], - "time": "2024-10-09T06:21:38+00:00" + "time": "2024-11-08T13:59:10+00:00" }, { - "name": "phpunit/php-file-iterator", - "version": "5.1.0", + "name": "sebastian/cli-parser", + "version": "3.0.2", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-file-iterator.git", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6" + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/118cfaaa8bc5aef3287bf315b6060b1174754af6", - "reference": "118cfaaa8bc5aef3287bf315b6060b1174754af6", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", + "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", "shasum": "" }, "require": { @@ -2482,7 +4622,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -2501,16 +4641,12 @@ "role": "lead" } ], - "description": "FilterIterator implementation that filters files based on a list of suffixes.", - "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", - "keywords": [ - "filesystem", - "iterator" - ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", "support": { - "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", - "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", - "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.0" + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" }, "funding": [ { @@ -2518,36 +4654,32 @@ "type": "github" } ], - "time": "2024-08-27T05:02:59+00:00" + "time": "2024-07-03T04:41:36+00:00" }, { - "name": "phpunit/php-invoker", - "version": "5.0.1", + "name": "sebastian/code-unit", + "version": "3.0.2", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-invoker.git", - "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2" + "url": "https://github.com/sebastianbergmann/code-unit.git", + "reference": "ee88b0cdbe74cf8dd3b54940ff17643c0d6543ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2", - "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/ee88b0cdbe74cf8dd3b54940ff17643c0d6543ca", + "reference": "ee88b0cdbe74cf8dd3b54940ff17643c0d6543ca", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "ext-pcntl": "*", - "phpunit/phpunit": "^11.0" - }, - "suggest": { - "ext-pcntl": "*" + "phpunit/phpunit": "^11.5" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "5.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -2566,15 +4698,12 @@ "role": "lead" } ], - "description": "Invoke callables with a timeout", - "homepage": "https://github.com/sebastianbergmann/php-invoker/", - "keywords": [ - "process" - ], + "description": "Collection of value objects that represent the PHP code units", + "homepage": "https://github.com/sebastianbergmann/code-unit", "support": { - "issues": "https://github.com/sebastianbergmann/php-invoker/issues", - "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", - "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1" + "issues": "https://github.com/sebastianbergmann/code-unit/issues", + "security": "https://github.com/sebastianbergmann/code-unit/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.2" }, "funding": [ { @@ -2582,20 +4711,20 @@ "type": "github" } ], - "time": "2024-07-03T05:07:44+00:00" + "time": "2024-12-12T09:59:06+00:00" }, { - "name": "phpunit/php-text-template", + "name": "sebastian/code-unit-reverse-lookup", "version": "4.0.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-text-template.git", - "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964" + "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964", - "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964", + "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", + "reference": "183a9b2632194febd219bb9246eee421dad8d45e", "shasum": "" }, "require": { @@ -2622,19 +4751,15 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "email": "sebastian@phpunit.de" } ], - "description": "Simple template engine.", - "homepage": "https://github.com/sebastianbergmann/php-text-template/", - "keywords": [ - "template" - ], + "description": "Looks up which function or method a line of code belongs to", + "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", "support": { - "issues": "https://github.com/sebastianbergmann/php-text-template/issues", - "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", - "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1" + "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", + "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", + "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" }, "funding": [ { @@ -2642,32 +4767,36 @@ "type": "github" } ], - "time": "2024-07-03T05:08:43+00:00" + "time": "2024-07-03T04:45:54+00:00" }, { - "name": "phpunit/php-timer", - "version": "7.0.1", + "name": "sebastian/comparator", + "version": "6.2.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/php-timer.git", - "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3" + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "43d129d6a0f81c78bee378b46688293eb7ea3739" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", - "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/43d129d6a0f81c78bee378b46688293eb7ea3739", + "reference": "43d129d6a0f81c78bee378b46688293eb7ea3739", "shasum": "" }, "require": { - "php": ">=8.2" + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/diff": "^6.0", + "sebastian/exporter": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.4" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "6.2-dev" } }, "autoload": { @@ -2682,19 +4811,32 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" } ], - "description": "Utility class for timing", - "homepage": "https://github.com/sebastianbergmann/php-timer/", + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", "keywords": [ - "timer" + "comparator", + "compare", + "equality" ], "support": { - "issues": "https://github.com/sebastianbergmann/php-timer/issues", - "security": "https://github.com/sebastianbergmann/php-timer/security/policy", - "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1" + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/6.2.1" }, "funding": [ { @@ -2702,65 +4844,36 @@ "type": "github" } ], - "time": "2024-07-03T05:09:35+00:00" + "time": "2024-10-31T05:30:08+00:00" }, { - "name": "phpunit/phpunit", - "version": "11.4.3", + "name": "sebastian/complexity", + "version": "4.0.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "e8e8ed1854de5d36c088ec1833beae40d2dedd76" + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/e8e8ed1854de5d36c088ec1833beae40d2dedd76", - "reference": "e8e8ed1854de5d36c088ec1833beae40d2dedd76", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", + "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-json": "*", - "ext-libxml": "*", - "ext-mbstring": "*", - "ext-xml": "*", - "ext-xmlwriter": "*", - "myclabs/deep-copy": "^1.12.0", - "phar-io/manifest": "^2.0.4", - "phar-io/version": "^3.2.1", - "php": ">=8.2", - "phpunit/php-code-coverage": "^11.0.7", - "phpunit/php-file-iterator": "^5.1.0", - "phpunit/php-invoker": "^5.0.1", - "phpunit/php-text-template": "^4.0.1", - "phpunit/php-timer": "^7.0.1", - "sebastian/cli-parser": "^3.0.2", - "sebastian/code-unit": "^3.0.1", - "sebastian/comparator": "^6.1.1", - "sebastian/diff": "^6.0.2", - "sebastian/environment": "^7.2.0", - "sebastian/exporter": "^6.1.3", - "sebastian/global-state": "^7.0.2", - "sebastian/object-enumerator": "^6.0.1", - "sebastian/type": "^5.1.0", - "sebastian/version": "^5.0.2" + "nikic/php-parser": "^5.0", + "php": ">=8.2" }, - "suggest": { - "ext-soap": "To be able to generate mocks based on WSDL files" + "require-dev": { + "phpunit/phpunit": "^11.0" }, - "bin": [ - "phpunit" - ], "type": "library", "extra": { "branch-alias": { - "dev-main": "11.4-dev" + "dev-main": "4.0-dev" } }, "autoload": { - "files": [ - "src/Framework/Assert/Functions.php" - ], "classmap": [ "src/" ] @@ -2776,58 +4889,46 @@ "role": "lead" } ], - "description": "The PHP Unit Testing framework.", - "homepage": "https://phpunit.de/", - "keywords": [ - "phpunit", - "testing", - "xunit" - ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", "support": { - "issues": "https://github.com/sebastianbergmann/phpunit/issues", - "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.4.3" + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" }, "funding": [ - { - "url": "https://phpunit.de/sponsors.html", - "type": "custom" - }, { "url": "https://github.com/sebastianbergmann", "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", - "type": "tidelift" } ], - "time": "2024-10-28T13:07:50+00:00" + "time": "2024-07-03T04:49:50+00:00" }, { - "name": "sebastian/cli-parser", - "version": "3.0.2", + "name": "sebastian/diff", + "version": "6.0.2", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/cli-parser.git", - "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180" + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180", - "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", + "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", "shasum": "" }, "require": { "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.0", + "symfony/process": "^4.2 || ^5" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -2842,16 +4943,25 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" } ], - "description": "Library for parsing CLI options", - "homepage": "https://github.com/sebastianbergmann/cli-parser", + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], "support": { - "issues": "https://github.com/sebastianbergmann/cli-parser/issues", - "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", - "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2" + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" }, "funding": [ { @@ -2859,20 +4969,20 @@ "type": "github" } ], - "time": "2024-07-03T04:41:36+00:00" + "time": "2024-07-03T04:53:05+00:00" }, { - "name": "sebastian/code-unit", - "version": "3.0.1", + "name": "sebastian/environment", + "version": "7.2.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit.git", - "reference": "6bb7d09d6623567178cf54126afa9c2310114268" + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/6bb7d09d6623567178cf54126afa9c2310114268", - "reference": "6bb7d09d6623567178cf54126afa9c2310114268", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5", + "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5", "shasum": "" }, "require": { @@ -2881,10 +4991,13 @@ "require-dev": { "phpunit/phpunit": "^11.0" }, + "suggest": { + "ext-posix": "*" + }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "7.2-dev" } }, "autoload": { @@ -2899,16 +5012,20 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "email": "sebastian@phpunit.de" } ], - "description": "Collection of value objects that represent the PHP code units", - "homepage": "https://github.com/sebastianbergmann/code-unit", + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], "support": { - "issues": "https://github.com/sebastianbergmann/code-unit/issues", - "security": "https://github.com/sebastianbergmann/code-unit/security/policy", - "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.1" + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/7.2.0" }, "funding": [ { @@ -2916,32 +5033,34 @@ "type": "github" } ], - "time": "2024-07-03T04:44:28+00:00" + "time": "2024-07-03T04:54:44+00:00" }, { - "name": "sebastian/code-unit-reverse-lookup", - "version": "4.0.1", + "name": "sebastian/exporter", + "version": "6.3.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", - "reference": "183a9b2632194febd219bb9246eee421dad8d45e" + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e", - "reference": "183a9b2632194febd219bb9246eee421dad8d45e", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/3473f61172093b2da7de1fb5782e1f24cc036dc3", + "reference": "3473f61172093b2da7de1fb5782e1f24cc036dc3", "shasum": "" }, "require": { - "php": ">=8.2" + "ext-mbstring": "*", + "php": ">=8.2", + "sebastian/recursion-context": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "6.1-dev" } }, "autoload": { @@ -2957,14 +5076,34 @@ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" } ], - "description": "Looks up which function or method a line of code belongs to", - "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], "support": { - "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", - "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", - "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1" + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.0" }, "funding": [ { @@ -2972,36 +5111,35 @@ "type": "github" } ], - "time": "2024-07-03T04:45:54+00:00" + "time": "2024-12-05T09:17:50+00:00" }, { - "name": "sebastian/comparator", - "version": "6.2.1", + "name": "sebastian/global-state", + "version": "7.0.2", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/comparator.git", - "reference": "43d129d6a0f81c78bee378b46688293eb7ea3739" + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/43d129d6a0f81c78bee378b46688293eb7ea3739", - "reference": "43d129d6a0f81c78bee378b46688293eb7ea3739", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", + "reference": "3be331570a721f9a4b5917f4209773de17f747d7", "shasum": "" }, "require": { - "ext-dom": "*", - "ext-mbstring": "*", "php": ">=8.2", - "sebastian/diff": "^6.0", - "sebastian/exporter": "^6.0" + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^11.4" + "ext-dom": "*", + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.2-dev" + "dev-main": "7.0-dev" } }, "autoload": { @@ -3017,31 +5155,17 @@ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de" - }, - { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" - }, - { - "name": "Volker Dusch", - "email": "github@wallbash.com" - }, - { - "name": "Bernhard Schussek", - "email": "bschussek@2bepublished.at" } ], - "description": "Provides the functionality to compare PHP values for equality", - "homepage": "https://github.com/sebastianbergmann/comparator", + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", "keywords": [ - "comparator", - "compare", - "equality" + "global state" ], "support": { - "issues": "https://github.com/sebastianbergmann/comparator/issues", - "security": "https://github.com/sebastianbergmann/comparator/security/policy", - "source": "https://github.com/sebastianbergmann/comparator/tree/6.2.1" + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" }, "funding": [ { @@ -3049,20 +5173,20 @@ "type": "github" } ], - "time": "2024-10-31T05:30:08+00:00" + "time": "2024-07-03T04:57:36+00:00" }, { - "name": "sebastian/complexity", - "version": "4.0.1", + "name": "sebastian/lines-of-code", + "version": "3.0.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/complexity.git", - "reference": "ee41d384ab1906c68852636b6de493846e13e5a0" + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0", - "reference": "ee41d384ab1906c68852636b6de493846e13e5a0", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", "shasum": "" }, "require": { @@ -3075,7 +5199,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "4.0-dev" + "dev-main": "3.0-dev" } }, "autoload": { @@ -3094,12 +5218,12 @@ "role": "lead" } ], - "description": "Library for calculating the complexity of PHP code units", - "homepage": "https://github.com/sebastianbergmann/complexity", + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", "support": { - "issues": "https://github.com/sebastianbergmann/complexity/issues", - "security": "https://github.com/sebastianbergmann/complexity/security/policy", - "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1" + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" }, "funding": [ { @@ -3107,28 +5231,29 @@ "type": "github" } ], - "time": "2024-07-03T04:49:50+00:00" + "time": "2024-07-03T04:58:38+00:00" }, { - "name": "sebastian/diff", - "version": "6.0.2", + "name": "sebastian/object-enumerator", + "version": "6.0.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/diff.git", - "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544" + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544", - "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", + "reference": "f5b498e631a74204185071eb41f33f38d64608aa", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.2", + "sebastian/object-reflector": "^4.0", + "sebastian/recursion-context": "^6.0" }, "require-dev": { - "phpunit/phpunit": "^11.0", - "symfony/process": "^4.2 || ^5" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { @@ -3149,24 +5274,14 @@ { "name": "Sebastian Bergmann", "email": "sebastian@phpunit.de" - }, - { - "name": "Kore Nordmann", - "email": "mail@kore-nordmann.de" } ], - "description": "Diff implementation", - "homepage": "https://github.com/sebastianbergmann/diff", - "keywords": [ - "diff", - "udiff", - "unidiff", - "unified diff" - ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", "support": { - "issues": "https://github.com/sebastianbergmann/diff/issues", - "security": "https://github.com/sebastianbergmann/diff/security/policy", - "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2" + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" }, "funding": [ { @@ -3174,20 +5289,20 @@ "type": "github" } ], - "time": "2024-07-03T04:53:05+00:00" + "time": "2024-07-03T05:00:13+00:00" }, { - "name": "sebastian/environment", - "version": "7.2.0", + "name": "sebastian/object-reflector", + "version": "4.0.1", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/environment.git", - "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5" + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5", - "reference": "855f3ae0ab316bbafe1ba4e16e9f3c078d24a0c5", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", "shasum": "" }, "require": { @@ -3196,13 +5311,10 @@ "require-dev": { "phpunit/phpunit": "^11.0" }, - "suggest": { - "ext-posix": "*" - }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.2-dev" + "dev-main": "4.0-dev" } }, "autoload": { @@ -3220,17 +5332,12 @@ "email": "sebastian@phpunit.de" } ], - "description": "Provides functionality to handle HHVM/PHP environments", - "homepage": "https://github.com/sebastianbergmann/environment", - "keywords": [ - "Xdebug", - "environment", - "hhvm" - ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", "support": { - "issues": "https://github.com/sebastianbergmann/environment/issues", - "security": "https://github.com/sebastianbergmann/environment/security/policy", - "source": "https://github.com/sebastianbergmann/environment/tree/7.2.0" + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" }, "funding": [ { @@ -3238,34 +5345,32 @@ "type": "github" } ], - "time": "2024-07-03T04:54:44+00:00" + "time": "2024-07-03T05:01:32+00:00" }, { - "name": "sebastian/exporter", - "version": "6.1.3", + "name": "sebastian/recursion-context", + "version": "6.0.2", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/exporter.git", - "reference": "c414673eee9a8f9d51bbf8d61fc9e3ef1e85b20e" + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "694d156164372abbd149a4b85ccda2e4670c0e16" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c414673eee9a8f9d51bbf8d61fc9e3ef1e85b20e", - "reference": "c414673eee9a8f9d51bbf8d61fc9e3ef1e85b20e", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/694d156164372abbd149a4b85ccda2e4670c0e16", + "reference": "694d156164372abbd149a4b85ccda2e4670c0e16", "shasum": "" }, "require": { - "ext-mbstring": "*", - "php": ">=8.2", - "sebastian/recursion-context": "^6.0" + "php": ">=8.2" }, "require-dev": { - "phpunit/phpunit": "^11.2" + "phpunit/phpunit": "^11.0" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "6.1-dev" + "dev-main": "6.0-dev" } }, "autoload": { @@ -3286,29 +5391,17 @@ "name": "Jeff Welch", "email": "whatthejeff@gmail.com" }, - { - "name": "Volker Dusch", - "email": "github@wallbash.com" - }, { "name": "Adam Harvey", "email": "aharvey@php.net" - }, - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" } ], - "description": "Provides the functionality to export PHP variables for visualization", - "homepage": "https://www.github.com/sebastianbergmann/exporter", - "keywords": [ - "export", - "exporter" - ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", "support": { - "issues": "https://github.com/sebastianbergmann/exporter/issues", - "security": "https://github.com/sebastianbergmann/exporter/security/policy", - "source": "https://github.com/sebastianbergmann/exporter/tree/6.1.3" + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.2" }, "funding": [ { @@ -3316,35 +5409,32 @@ "type": "github" } ], - "time": "2024-07-03T04:56:19+00:00" + "time": "2024-07-03T05:10:34+00:00" }, { - "name": "sebastian/global-state", - "version": "7.0.2", + "name": "sebastian/type", + "version": "5.1.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/global-state.git", - "reference": "3be331570a721f9a4b5917f4209773de17f747d7" + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "461b9c5da241511a2a0e8f240814fb23ce5c0aac" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7", - "reference": "3be331570a721f9a4b5917f4209773de17f747d7", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/461b9c5da241511a2a0e8f240814fb23ce5c0aac", + "reference": "461b9c5da241511a2a0e8f240814fb23ce5c0aac", "shasum": "" }, "require": { - "php": ">=8.2", - "sebastian/object-reflector": "^4.0", - "sebastian/recursion-context": "^6.0" + "php": ">=8.2" }, "require-dev": { - "ext-dom": "*", - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^11.3" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "7.0-dev" + "dev-main": "5.1-dev" } }, "autoload": { @@ -3359,18 +5449,16 @@ "authors": [ { "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "email": "sebastian@phpunit.de", + "role": "lead" } ], - "description": "Snapshotting of global state", - "homepage": "https://www.github.com/sebastianbergmann/global-state", - "keywords": [ - "global state" - ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", "support": { - "issues": "https://github.com/sebastianbergmann/global-state/issues", - "security": "https://github.com/sebastianbergmann/global-state/security/policy", - "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2" + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/5.1.0" }, "funding": [ { @@ -3378,33 +5466,29 @@ "type": "github" } ], - "time": "2024-07-03T04:57:36+00:00" + "time": "2024-09-17T13:12:04+00:00" }, { - "name": "sebastian/lines-of-code", - "version": "3.0.1", + "name": "sebastian/version", + "version": "5.0.2", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/lines-of-code.git", - "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a" + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a", - "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", + "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", "shasum": "" }, "require": { - "nikic/php-parser": "^5.0", "php": ">=8.2" }, - "require-dev": { - "phpunit/phpunit": "^11.0" - }, "type": "library", "extra": { "branch-alias": { - "dev-main": "3.0-dev" + "dev-main": "5.0-dev" } }, "autoload": { @@ -3423,12 +5507,12 @@ "role": "lead" } ], - "description": "Library for counting the lines of code in PHP source code", - "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", "support": { - "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", - "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", - "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1" + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" }, "funding": [ { @@ -3436,296 +5520,344 @@ "type": "github" } ], - "time": "2024-07-03T04:58:38+00:00" + "time": "2024-10-09T05:16:32+00:00" }, { - "name": "sebastian/object-enumerator", - "version": "6.0.1", + "name": "staabm/side-effects-detector", + "version": "1.0.5", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/object-enumerator.git", - "reference": "f5b498e631a74204185071eb41f33f38d64608aa" + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa", - "reference": "f5b498e631a74204185071eb41f33f38d64608aa", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", "shasum": "" }, "require": { - "php": ">=8.2", - "sebastian/object-reflector": "^4.0", - "sebastian/recursion-context": "^6.0" + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "^11.0" + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "6.0-dev" - } - }, "autoload": { "classmap": [ - "src/" + "lib/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], - "authors": [ - { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" - } + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" ], - "description": "Traverses array structures and object graphs to enumerate all referenced objects", - "homepage": "https://github.com/sebastianbergmann/object-enumerator/", "support": { - "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", - "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", - "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1" + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://github.com/staabm", "type": "github" } ], - "time": "2024-07-03T05:00:13+00:00" + "time": "2024-10-20T05:08:20+00:00" }, { - "name": "sebastian/object-reflector", - "version": "4.0.1", + "name": "symfony/options-resolver", + "version": "v7.2.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/object-reflector.git", - "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9" + "url": "https://github.com/symfony/options-resolver.git", + "reference": "7da8fbac9dcfef75ffc212235d76b2754ce0cf50" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9", - "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/7da8fbac9dcfef75ffc212235d76b2754ce0cf50", + "reference": "7da8fbac9dcfef75ffc212235d76b2754ce0cf50", "shasum": "" }, "require": { - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.0" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "4.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Allows reflection of object attributes, including inherited and non-public ones", - "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "description": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], "support": { - "issues": "https://github.com/sebastianbergmann/object-reflector/issues", - "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", - "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1" + "source": "https://github.com/symfony/options-resolver/tree/v7.2.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2024-07-03T05:01:32+00:00" + "time": "2024-11-20T11:17:29+00:00" }, { - "name": "sebastian/recursion-context", - "version": "6.0.2", + "name": "symfony/polyfill-php80", + "version": "v1.31.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/recursion-context.git", - "reference": "694d156164372abbd149a4b85ccda2e4670c0e16" + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/694d156164372abbd149a4b85ccda2e4670c0e16", - "reference": "694d156164372abbd149a4b85ccda2e4670c0e16", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", + "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", "shasum": "" }, "require": { - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.0" + "php": ">=7.2" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "6.0-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, "classmap": [ - "src/" + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de" + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" }, { - "name": "Jeff Welch", - "email": "whatthejeff@gmail.com" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" }, { - "name": "Adam Harvey", - "email": "aharvey@php.net" + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Provides functionality to recursively process PHP variables", - "homepage": "https://github.com/sebastianbergmann/recursion-context", + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], "support": { - "issues": "https://github.com/sebastianbergmann/recursion-context/issues", - "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", - "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.2" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2024-07-03T05:10:34+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { - "name": "sebastian/type", - "version": "5.1.0", + "name": "symfony/polyfill-php81", + "version": "v1.31.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/type.git", - "reference": "461b9c5da241511a2a0e8f240814fb23ce5c0aac" + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/461b9c5da241511a2a0e8f240814fb23ce5c0aac", - "reference": "461b9c5da241511a2a0e8f240814fb23ce5c0aac", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", "shasum": "" }, "require": { - "php": ">=8.2" - }, - "require-dev": { - "phpunit/phpunit": "^11.3" + "php": ">=7.2" }, "type": "library", "extra": { - "branch-alias": { - "dev-main": "5.1-dev" + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" } }, "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, "classmap": [ - "src/" + "Resources/stubs" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Collection of value objects that represent the types of the PHP type system", - "homepage": "https://github.com/sebastianbergmann/type", + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], "support": { - "issues": "https://github.com/sebastianbergmann/type/issues", - "security": "https://github.com/sebastianbergmann/type/security/policy", - "source": "https://github.com/sebastianbergmann/type/tree/5.1.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.31.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2024-09-17T13:12:04+00:00" + "time": "2024-09-09T11:45:10+00:00" }, { - "name": "sebastian/version", - "version": "5.0.2", + "name": "symfony/stopwatch", + "version": "v7.2.0", "source": { "type": "git", - "url": "https://github.com/sebastianbergmann/version.git", - "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874" + "url": "https://github.com/symfony/stopwatch.git", + "reference": "696f418b0d722a4225e1c3d95489d262971ca924" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874", - "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874", + "url": "https://api.github.com/repos/symfony/stopwatch/zipball/696f418b0d722a4225e1c3d95489d262971ca924", + "reference": "696f418b0d722a4225e1c3d95489d262971ca924", "shasum": "" }, "require": { - "php": ">=8.2" + "php": ">=8.2", + "symfony/service-contracts": "^2.5|^3" }, "type": "library", - "extra": { - "branch-alias": { - "dev-main": "5.0-dev" - } - }, "autoload": { - "classmap": [ - "src/" + "psr-4": { + "Symfony\\Component\\Stopwatch\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" ] }, "notification-url": "https://packagist.org/downloads/", "license": [ - "BSD-3-Clause" + "MIT" ], "authors": [ { - "name": "Sebastian Bergmann", - "email": "sebastian@phpunit.de", - "role": "lead" + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" } ], - "description": "Library that helps with managing the version number of Git-hosted PHP projects", - "homepage": "https://github.com/sebastianbergmann/version", + "description": "Provides a way to profile code", + "homepage": "https://symfony.com", "support": { - "issues": "https://github.com/sebastianbergmann/version/issues", - "security": "https://github.com/sebastianbergmann/version/security/policy", - "source": "https://github.com/sebastianbergmann/version/tree/5.0.2" + "source": "https://github.com/symfony/stopwatch/tree/v7.2.0" }, "funding": [ { - "url": "https://github.com/sebastianbergmann", + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" } ], - "time": "2024-10-09T05:16:32+00:00" + "time": "2024-09-25T14:21:43+00:00" }, { "name": "theseer/tokenizer", @@ -3788,7 +5920,10 @@ ], "minimum-stability": "stable", "stability-flags": { - "padraic/humbug_get_contents": 20 + "padraic/humbug_get_contents": 20, + "platformsh/client": 10, + "platformsh/console-form": 10, + "platformsh/oauth2": 10 }, "prefer-stable": false, "prefer-lowest": false, diff --git a/config-defaults.yaml b/config-defaults.yaml index f01b5c0c6f..c1fef5ab70 100644 --- a/config-defaults.yaml +++ b/config-defaults.yaml @@ -53,10 +53,6 @@ application: # instances are installed. prompt_self_install: true - # The default interactive login method: either 'browser' or 'api-token'. - # This can be overridden in the user config file. - login_method: browser - # The default timezone for times displayed or interpreted by the CLI. # An empty (falsy) value here means the PHP or system timezone will be used. # For a list of timezones, see: http://php.net/manual/en/timezones.php diff --git a/config/.gitignore b/config/.gitignore new file mode 100644 index 0000000000..14d86ad623 --- /dev/null +++ b/config/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/config/services.yaml b/config/services.yaml new file mode 100644 index 0000000000..b65170702a --- /dev/null +++ b/config/services.yaml @@ -0,0 +1,50 @@ +# Configuration file for services (dependency injection). +# +# See https://symfony.com/doc/current/service_container.html +services: + _defaults: + autowire: true + autoconfigure: false + + # Set up all classes in src/Command as console commands. + Platformsh\Cli\Command\: + resource: '../src/Command/*' + tags: [console.command] + + # Set up all classes in src/Service as services automatically. + Platformsh\Cli\Service\: + resource: '../src/Service/*' + + # A few services are not inside the \Platformsh\Cli\Service namespace. + Platformsh\Cli\Local\ApplicationFinder: {} + Platformsh\Cli\Local\LocalBuild: {} + Platformsh\Cli\Local\LocalProject: + public: true + Platformsh\Cli\SshCert\Certifier: {} + Platformsh\Cli\Selector\Selector: {} + + # A few other services have to be public; they are used in Application. + Platformsh\Cli\Service\LegacyMigration: + public: true + Platformsh\Cli\Service\SelfInstallChecker: + public: true + Platformsh\Cli\Service\SelfUpdateChecker: + public: true + + # Configure the cache service, which is created via a factory. + Doctrine\Common\Cache\CacheProvider: + public: true + factory: 'Platformsh\Cli\Service\CacheFactory::createCacheProvider' + arguments: ['@Platformsh\Cli\Service\Config'] + + # Configure synthetic services, which may be created and set after the + # container is compiled. + Platformsh\Cli\Service\Config: + public: true + synthetic: true + Symfony\Component\Console\Output\OutputInterface: + public: true + synthetic: true + Symfony\Component\Console\Input\InputInterface: + public: true + synthetic: true diff --git a/constants.php b/constants.php index c9dc8c0c83..4b0e5d94ac 100644 --- a/constants.php +++ b/constants.php @@ -1,4 +1,7 @@ get('application.name'); -$envPrefix = $config->get('service.env_prefix'); -$branch = getenv($envPrefix . 'BRANCH', true); -$treeId = getenv($envPrefix . 'TREE_ID', true); +$appName = $config->getStr('application.name'); +$envPrefix = $config->getStr('service.env_prefix'); +$branch = (string) getenv($envPrefix . 'BRANCH', true); +$treeId = (string) getenv($envPrefix . 'TREE_ID', true); $pharUrl = getenv('CLI_URL_PATH', true) ?: 'platform.phar'; -$pharHash = hash_file('sha256', __DIR__ . '/' . ltrim(getenv('CLI_URL_PATH', true), '/')); +$pharHash = hash_file('sha256', __DIR__ . '/' . ltrim((string) getenv('CLI_URL_PATH', true), '/')); if ($timestamp = getenv('CLI_BUILD_DATE', true)) { - $pharDate = date('c', is_int($timestamp) ? $timestamp : strtotime($timestamp)); + $pharDate = date('c', is_numeric($timestamp) ? (int) $timestamp : (int) strtotime($timestamp)); } else { $pharDate = false; } -if ($config->getWithDefault('application.github_repo', '')) { - $sourceLink = 'https://github.com/' . $config->get('application.github_repo'); +if ($config->has('application.github_repo')) { + $sourceLink = 'https://github.com/' . $config->getStr('application.github_repo'); $sourceLinkSpecific = $sourceLink; if ($branch) { - if (strpos($branch, 'pr-') === 0 && is_numeric(substr($branch, 3))) { + if (str_starts_with($branch, 'pr-') && is_numeric(substr($branch, 3))) { $sourceLinkSpecific .= '/pull/' . substr($branch, 3); } else { $sourceLinkSpecific .= '/tree/' . rawurlencode($branch); @@ -48,7 +48,7 @@ if ($config->has('application.installer_url')) { $revertScript = sprintf( 'curl -sfS %s | php', - $config->get('application.installer_url') + $config->getStr('application.installer_url'), ); } @@ -118,11 +118,10 @@

Development build

- -

- Download: -

- +

+ Download: +

+

Build date: @@ -148,18 +147,17 @@ Source:

- -

Testing instructions

-

- Install this version with:
- -

- -

- Install the stable version again with:
- -

- + +

Testing instructions

+

+ Install this version with:
+ +

+ +

+ Install the stable version again with:
+ +

diff --git a/dist/installer.php b/dist/installer.php index 7618236405..13d7146813 100644 --- a/dist/installer.php +++ b/dist/installer.php @@ -53,7 +53,8 @@ (new Installer())->run(); } -class Installer { +class Installer +{ private $envPrefix; private $manifestUrl; private $configDir; @@ -66,7 +67,8 @@ class Installer { private $migratePrompt = false; private $migrateDocsUrl; - public function __construct(array $args = []) { + public function __construct(array $args = []) + { $this->argv = !empty($args) ? $args : $GLOBALS['argv']; // This config is automatically replaced by the self:build command, @@ -115,7 +117,8 @@ public function __construct(array $args = []) { /** * Runs the install itself. */ - public function run() { + public function run() + { error_reporting(E_ALL); ini_set('log_errors', 0); ini_set('display_errors', 1); @@ -137,7 +140,7 @@ public function run() { $this->output(''); $waitTime = 10; // Check STDIN in a loop to see if the user hit a key. - if ($this->isTerminal(STDIN) && stream_set_blocking(STDIN, FALSE)) { + if ($this->isTerminal(STDIN) && stream_set_blocking(STDIN, false)) { $start = microtime(true); $this->output("Continuing with the installation in $waitTime seconds. Press Enter to continue now, or Ctrl+C to quit."); while (microtime(true) - $start < $waitTime) { @@ -334,7 +337,8 @@ function () { * * @param string $extension */ - private function checkExtension($extension) { + private function checkExtension($extension) + { if (\extension_loaded($extension)) { $this->output(' [*] The "' . $extension . '" PHP extension is installed.', 'success'); return; @@ -375,7 +379,8 @@ private function checkExtension($extension) { * * @return int The command's exit code. */ - private function selfInstall($pharPath) { + private function selfInstall($pharPath) + { $command = 'php ' . escapeshellarg($pharPath) . ' self:install --yes'; if ($shellType = $this->getOption('shell-type')) { $command .= ' --shell-type ' . escapeshellarg($shellType); @@ -392,7 +397,8 @@ private function selfInstall($pharPath) { * * @return TaskResult */ - private function findLatestVersion($manifestUrl) { + private function findLatestVersion($manifestUrl) + { $manifest = file_get_contents($manifestUrl, false, \stream_context_create($this->getStreamContextOpts(15))); if ($manifest === false) { return TaskResult::failure('Failed to download manifest file: ' . $manifestUrl); @@ -430,7 +436,8 @@ private function findLatestVersion($manifestUrl) { * * @return mixed The result of the task, if any. */ - private function performTask($summaryText, $task, $indent = ' ') { + private function performTask($summaryText, $task, $indent = ' ') + { $this->output($indent . $summaryText . '...', null, false); /** @var TaskResult $result */ $result = $task(); @@ -475,7 +482,8 @@ private function ensureStreamConstants() * * @return int The command's exit code. */ - private function runCommand($cmd, $forceStdout = false) { + private function runCommand($cmd, $forceStdout = false) + { $process = proc_open($cmd, [STDIN, STDOUT, $forceStdout ? STDOUT : STDERR], $pipes); return proc_close($process); @@ -489,14 +497,13 @@ private function runCommand($cmd, $forceStdout = false) { * @param callable $condition The condition to check. * @param bool $exit Whether to exit on failure. */ - private function check($success, $failure, $condition, $exit = true) { + private function check($success, $failure, $condition, $exit = true) + { if ($condition()) { $this->output(' [*] ' . $success, 'success'); - } - elseif (!$exit) { + } elseif (!$exit) { $this->output(' [!] ' . $failure, 'warning'); - } - else { + } else { $this->output(' [X] ' . $failure, 'error'); exit(1); } @@ -509,7 +516,8 @@ private function check($success, $failure, $condition, $exit = true) { * @param string $color * @param bool $newLine */ - private function output($text, $color = null, $newLine = true) { + private function output($text, $color = null, $newLine = true) + { static $styles = [ 'success' => "\033[0;32m%s\033[0m", 'error' => "\033[31;31m%s\033[0m", @@ -538,7 +546,8 @@ private function output($text, $color = null, $newLine = true) { * * @return bool */ - private function flagEnabled($flag) { + private function flagEnabled($flag) + { return in_array('--' . $flag, $this->argv, true); } @@ -549,7 +558,8 @@ private function flagEnabled($flag) { * * @return string */ - private function getOption($name) { + private function getOption($name) + { foreach ($this->argv as $key => $arg) { if (strpos($arg, '--' . $name . '=') === 0) { return substr($arg, strlen('--' . $name . '=')); @@ -573,7 +583,8 @@ private function getOption($name) { * * @return bool */ - private function terminalSupportsAnsi() { + private function terminalSupportsAnsi() + { static $ansi; if (isset($ansi)) { return $ansi; @@ -583,8 +594,7 @@ private function terminalSupportsAnsi() { if (!empty($argv)) { if ($this->flagEnabled('no-ansi')) { return $ansi = false; - } - elseif ($this->flagEnabled('ansi')) { + } elseif ($this->flagEnabled('ansi')) { return $ansi = true; } } @@ -603,7 +613,8 @@ private function terminalSupportsAnsi() { * @return string|false * The user's home directory as an absolute path, or false on failure. */ - private function getHomeDirectory() { + private function getHomeDirectory() + { $vars = [$this->envPrefix . 'HOME', 'HOME', 'USERPROFILE']; foreach ($vars as $var) { if ($home = getenv($var)) { @@ -624,7 +635,8 @@ private function getHomeDirectory() { * * @return array */ - private function getStreamContextOpts($timeout) { + private function getStreamContextOpts($timeout) + { $opts = [ 'http' => [ 'method' => 'GET', @@ -659,7 +671,8 @@ private function getStreamContextOpts($timeout) { * * @return string|false */ - private function getCaBundle() { + private function getCaBundle() + { static $path; if (isset($path)) { return $path; @@ -748,7 +761,8 @@ private function caPathUsable($path) * @return string * An authenticated redirection URL, if possible. Otherwise the original URL is returned. */ - private function getAuthenticatedRedirect($url) { + private function getAuthenticatedRedirect($url) + { if (\strpos($url, '//github.com') === false) { return $url; } @@ -783,7 +797,8 @@ private function getAuthenticatedRedirect($url) { * * @return string[] */ - private function authHeaders($url) { + private function authHeaders($url) + { $host = \parse_url($url, PHP_URL_HOST); if ($host === 'github.com') { @@ -820,7 +835,8 @@ private function authHeaders($url) { * * @return string|null */ - private function getProxy() { + private function getProxy() + { // The proxy variables should be ignored in a non-CLI context. // This check has probably already been run, but it's important. if (PHP_SAPI !== 'cli') { @@ -847,7 +863,7 @@ private function isInteractive() /** * Detects if running in a TTY terminal. * - * @see \Platformsh\Cli\Command\CommandBase::isTerminal() + * @see \Platformsh\Cli\Service\Io::isTerminal() * * @param resource|int $descriptor * @@ -891,39 +907,47 @@ private function isCI() } } -class TaskResult { +class TaskResult +{ private $success = false; private $message = ''; private $data; - private function __construct($success, $message = '', $data = null) { + private function __construct($success, $message = '', $data = null) + { $this->success = $success; $this->message = $message; $this->data = $data; } - public static function success($data = null) { + public static function success($data = null) + { return new self(true, '', $data); } - public static function failure($errorMessage) { + public static function failure($errorMessage) + { return new self(false, $errorMessage); } - public function isSuccess() { + public function isSuccess() + { return $this->success; } - public function getMessage() { + public function getMessage() + { return $this->message; } - public function getData() { + public function getData() + { return $this->data; } } -class VersionResolver { +class VersionResolver +{ /** * Finds the latest installable version in the manifest. * @@ -934,7 +958,8 @@ class VersionResolver { * @return array * A list of versions, filtered by those that are installable. */ - public function findInstallableVersions(array $versions, $phpVersion = PHP_VERSION, array $allowedSuffixes = ['stable']) { + public function findInstallableVersions(array $versions, $phpVersion = PHP_VERSION, array $allowedSuffixes = ['stable']) + { $installable = []; foreach ($versions as $version) { if (isset($version['php']['min']) && version_compare($version['php']['min'], $phpVersion, '>')) { @@ -961,7 +986,8 @@ public function findInstallableVersions(array $versions, $phpVersion = PHP_VERSI * * @return string */ - public function explainNoInstallableVersions(array $versions, $phpVersion = PHP_VERSION, array $allowedSuffixes = ['stable']) { + public function explainNoInstallableVersions(array $versions, $phpVersion = PHP_VERSION, array $allowedSuffixes = ['stable']) + { $reasons = []; foreach ($versions as $version) { $name = 'v' . $version['version']; @@ -999,7 +1025,8 @@ public function explainNoInstallableVersions(array $versions, $phpVersion = PHP_ * * @return array */ - public function findLatestVersion(array $versions, $min = '', $max = '') { + public function findLatestVersion(array $versions, $min = '', $max = '') + { usort($versions, function (array $a, array $b) { return version_compare($a['version'], $b['version']); }); diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000000..d7aca79716 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,401 @@ +parameters: + ignoreErrors: + - + message: "#^Call to an undefined method Platformsh\\\\Client\\\\Connection\\\\ConnectorInterface\\:\\:getOAuth2Provider\\(\\)\\.$#" + count: 1 + path: src/Command/Auth/ApiTokenLoginCommand.php + + - + message: "#^Call to an undefined method Platformsh\\\\Client\\\\Connection\\\\ConnectorInterface\\:\\:saveToken\\(\\)\\.$#" + count: 1 + path: src/Command/Auth/ApiTokenLoginCommand.php + + - + message: "#^Call to an undefined method GuzzleHttp\\\\ClientInterface\\:\\:post\\(\\)\\.$#" + count: 3 + path: src/Command/Auth/VerifyPhoneNumberCommand.php + + - + message: "#^Access to an undefined property Platformsh\\\\Client\\\\Model\\\\Backup\\:\\:\\$safe\\.$#" + count: 2 + path: src/Command/Backup/BackupListCommand.php + + - + message: "#^Call to an undefined method GuzzleHttp\\\\ClientInterface\\:\\:delete\\(\\)\\.$#" + count: 1 + path: src/Command/BlueGreen/BlueGreenConcludeCommand.php + + - + message: "#^Call to an undefined method GuzzleHttp\\\\ClientInterface\\:\\:get\\(\\)\\.$#" + count: 1 + path: src/Command/BlueGreen/BlueGreenConcludeCommand.php + + - + message: "#^Call to an undefined method GuzzleHttp\\\\ClientInterface\\:\\:get\\(\\)\\.$#" + count: 1 + path: src/Command/BlueGreen/BlueGreenDeployCommand.php + + - + message: "#^Call to an undefined method GuzzleHttp\\\\ClientInterface\\:\\:patch\\(\\)\\.$#" + count: 1 + path: src/Command/BlueGreen/BlueGreenDeployCommand.php + + - + message: "#^Call to an undefined method GuzzleHttp\\\\ClientInterface\\:\\:get\\(\\)\\.$#" + count: 1 + path: src/Command/BlueGreen/BlueGreenEnableCommand.php + + - + message: "#^Call to an undefined method GuzzleHttp\\\\ClientInterface\\:\\:post\\(\\)\\.$#" + count: 1 + path: src/Command/BlueGreen/BlueGreenEnableCommand.php + + - + message: "#^While loop condition is always true\\.$#" + count: 1 + path: src/Command/BotCommand.php + + - + message: "#^Call to an undefined method GuzzleHttp\\\\ClientInterface\\:\\:post\\(\\)\\.$#" + count: 1 + path: src/Command/Integration/IntegrationCommandBase.php + + - + message: "#^Cannot call method set\\(\\) on Platformsh\\\\ConsoleForm\\\\Field\\\\Field\\|false\\.$#" + count: 1 + path: src/Command/Organization/OrganizationCreateCommand.php + + - + message: "#^Call to an undefined method GuzzleHttp\\\\ClientInterface\\:\\:get\\(\\)\\.$#" + count: 1 + path: src/Command/Project/ProjectCreateCommand.php + + - + message: "#^Access to an undefined property Platformsh\\\\Client\\\\Model\\\\Deployment\\\\Service\\|Platformsh\\\\Client\\\\Model\\\\Deployment\\\\WebApp\\|Platformsh\\\\Client\\\\Model\\\\Deployment\\\\Worker\\:\\:\\$container_profile\\.$#" + count: 3 + path: src/Command/Resources/ResourcesSizeListCommand.php + + - + message: "#^Parameter \\#1 \\$items of method Platformsh\\\\Cli\\\\Service\\\\QuestionHelper\\:\\:choose\\(\\) expects array\\, array\\ given\\.$#" + count: 1 + path: src/Command/SshKey/SshKeyDeleteCommand.php + + - + message: "#^Call to an undefined method GuzzleHttp\\\\ClientInterface\\:\\:post\\(\\)\\.$#" + count: 1 + path: src/Command/Team/Project/TeamProjectAddCommand.php + + - + message: "#^Call to an undefined method GuzzleHttp\\\\ClientInterface\\:\\:delete\\(\\)\\.$#" + count: 1 + path: src/Command/Team/Project/TeamProjectDeleteCommand.php + + - + message: "#^Call to an undefined method GuzzleHttp\\\\ClientInterface\\:\\:get\\(\\)\\.$#" + count: 1 + path: src/Command/Team/TeamListCommand.php + + - + message: "#^Call to an undefined method GuzzleHttp\\\\ClientInterface\\:\\:post\\(\\)\\.$#" + count: 1 + path: src/Command/Team/User/TeamUserAddCommand.php + + - + message: "#^Dead catch \\- Platformsh\\\\ConsoleForm\\\\Exception\\\\ConditionalFieldException is never thrown in the try block\\.$#" + count: 1 + path: src/Command/Variable/VariableCreateCommand.php + + - + message: "#^Call to an undefined method GuzzleHttp\\\\ClientInterface\\:\\:get\\(\\)\\.$#" + count: 1 + path: src/Command/Version/VersionListCommand.php + + - + message: "#^While loop condition is always true\\.$#" + count: 1 + path: src/Command/WinkyCommand.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Console\\\\CustomJsonDescriptor\\:\\:describeApplication\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Console/CustomJsonDescriptor.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Console\\\\CustomJsonDescriptor\\:\\:describeCommand\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Console/CustomJsonDescriptor.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Console\\\\CustomJsonDescriptor\\:\\:describeInputArgument\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Console/CustomJsonDescriptor.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Console\\\\CustomJsonDescriptor\\:\\:describeInputDefinition\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Console/CustomJsonDescriptor.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Console\\\\CustomJsonDescriptor\\:\\:describeInputOption\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Console/CustomJsonDescriptor.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Console\\\\CustomJsonDescriptor\\:\\:getCommandData\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Console/CustomJsonDescriptor.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Console\\\\CustomJsonDescriptor\\:\\:getInputArgumentData\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Console/CustomJsonDescriptor.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Console\\\\CustomJsonDescriptor\\:\\:getInputDefinitionData\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Console/CustomJsonDescriptor.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Console\\\\CustomJsonDescriptor\\:\\:getInputOptionData\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Console/CustomJsonDescriptor.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Console\\\\CustomJsonDescriptor\\:\\:writeData\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Console/CustomJsonDescriptor.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Console\\\\CustomJsonDescriptor\\:\\:writeData\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Console/CustomJsonDescriptor.php + + - + message: "#^Parameter \\#1 \\$content of method Symfony\\\\Component\\\\Console\\\\Descriptor\\\\Descriptor\\:\\:write\\(\\) expects string, string\\|false given\\.$#" + count: 1 + path: src/Console/CustomJsonDescriptor.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Console\\\\CustomMarkdownDescriptor\\:\\:describeApplication\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Console/CustomMarkdownDescriptor.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Console\\\\CustomMarkdownDescriptor\\:\\:describeCommand\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Console/CustomMarkdownDescriptor.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Console\\\\CustomMarkdownDescriptor\\:\\:describeInputArgument\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Console/CustomMarkdownDescriptor.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Console\\\\CustomMarkdownDescriptor\\:\\:describeInputOption\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Console/CustomMarkdownDescriptor.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Console\\\\CustomTextDescriptor\\:\\:describeApplication\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Console/CustomTextDescriptor.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Console\\\\CustomTextDescriptor\\:\\:describeCommand\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Console/CustomTextDescriptor.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Console\\\\CustomTextDescriptor\\:\\:describeInputOption\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Console/CustomTextDescriptor.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Console\\\\CustomTextDescriptor\\:\\:formatAliases\\(\\) has parameter \\$aliases with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Console/CustomTextDescriptor.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Console\\\\CustomTextDescriptor\\:\\:getColumnWidth\\(\\) has parameter \\$commands with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Console/CustomTextDescriptor.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Console\\\\CustomTextDescriptor\\:\\:writeText\\(\\) has parameter \\$options with no value type specified in iterable type array\\.$#" + count: 1 + path: src/Console/CustomTextDescriptor.php + + - + message: "#^Method Platformsh\\\\Cli\\\\CredentialHelper\\\\SessionStorage\\:\\:load\\(\\) return type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/CredentialHelper/SessionStorage.php + + - + message: "#^Method Platformsh\\\\Cli\\\\CredentialHelper\\\\SessionStorage\\:\\:save\\(\\) has parameter \\$data with no value type specified in iterable type array\\.$#" + count: 1 + path: src/CredentialHelper/SessionStorage.php + + - + message: "#^Property Platformsh\\\\Cli\\\\Exception\\\\ConnectionFailedException\\:\\:\\$code has no type specified\\.$#" + count: 1 + path: src/Exception/ConnectionFailedException.php + + - + message: "#^Property Platformsh\\\\Cli\\\\Exception\\\\ConnectionFailedException\\:\\:\\$message has no type specified\\.$#" + count: 1 + path: src/Exception/ConnectionFailedException.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Exception\\\\DependencyMissingException\\:\\:__construct\\(\\) has parameter \\$message with no type specified\\.$#" + count: 1 + path: src/Exception/DependencyMissingException.php + + - + message: "#^Property Platformsh\\\\Cli\\\\Exception\\\\InvalidConfigException\\:\\:\\$code has no type specified\\.$#" + count: 1 + path: src/Exception/InvalidConfigException.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Exception\\\\LoginRequiredException\\:\\:__construct\\(\\) has parameter \\$message with no type specified\\.$#" + count: 1 + path: src/Exception/LoginRequiredException.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Exception\\\\LoginRequiredException\\:\\:__construct\\(\\) has parameter \\$previous with no type specified\\.$#" + count: 1 + path: src/Exception/LoginRequiredException.php + + - + message: "#^Property Platformsh\\\\Cli\\\\Exception\\\\LoginRequiredException\\:\\:\\$code has no type specified\\.$#" + count: 1 + path: src/Exception/LoginRequiredException.php + + - + message: "#^Property Platformsh\\\\Cli\\\\Exception\\\\LoginRequiredException\\:\\:\\$message has no type specified\\.$#" + count: 1 + path: src/Exception/LoginRequiredException.php + + - + message: "#^Property Platformsh\\\\Cli\\\\Exception\\\\PermissionDeniedException\\:\\:\\$code has no type specified\\.$#" + count: 1 + path: src/Exception/PermissionDeniedException.php + + - + message: "#^Property Platformsh\\\\Cli\\\\Exception\\\\PermissionDeniedException\\:\\:\\$message has no type specified\\.$#" + count: 1 + path: src/Exception/PermissionDeniedException.php + + - + message: "#^Property Platformsh\\\\Cli\\\\Exception\\\\ProjectNotFoundException\\:\\:\\$code has no type specified\\.$#" + count: 1 + path: src/Exception/ProjectNotFoundException.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Exception\\\\RootNotFoundException\\:\\:__construct\\(\\) has parameter \\$code with no type specified\\.$#" + count: 1 + path: src/Exception/RootNotFoundException.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Exception\\\\RootNotFoundException\\:\\:__construct\\(\\) has parameter \\$message with no type specified\\.$#" + count: 1 + path: src/Exception/RootNotFoundException.php + + - + message: "#^Access to an undefined property PhpParser\\\\Node\\\\Expr\\\\Error\\|PhpParser\\\\Node\\\\Expr\\\\Variable\\:\\:\\$name\\.$#" + count: 3 + path: src/Rector/DependencyInjection.php + + - + message: "#^Access to an undefined property PhpParser\\\\Node\\\\Expr\\|PhpParser\\\\Node\\\\Identifier\\:\\:\\$name\\.$#" + count: 2 + path: src/Rector/InjectCommandServicesRector.php + + - + message: "#^Parameter \\#2 \\$callable of method Rector\\\\Rector\\\\AbstractRector\\:\\:traverseNodesWithCallable\\(\\) expects callable\\(PhpParser\\\\Node\\)\\: \\(array\\\\|int\\|PhpParser\\\\Node\\|null\\), Closure\\(PhpParser\\\\NodeAbstract\\)\\: \\(PhpParser\\\\Node\\\\Expr\\\\Assign\\|null\\) given\\.$#" + count: 1 + path: src/Rector/InjectCommandServicesRector.php + + - + message: "#^Access to an undefined property PhpParser\\\\Node\\\\Expr\\|PhpParser\\\\Node\\\\Identifier\\:\\:\\$name\\.$#" + count: 1 + path: src/Rector/NewServicesRector.php + + - + message: "#^Parameter \\#2 \\$callable of method Rector\\\\Rector\\\\AbstractRector\\:\\:traverseNodesWithCallable\\(\\) expects callable\\(PhpParser\\\\Node\\)\\: \\(array\\\\|int\\|PhpParser\\\\Node\\|null\\), Closure\\(PhpParser\\\\NodeAbstract\\)\\: \\(PhpParser\\\\Node\\\\Expr\\\\MethodCall\\|null\\) given\\.$#" + count: 1 + path: src/Rector/NewServicesRector.php + + - + message: "#^Property PhpParser\\\\Node\\\\Expr\\\\MethodCall\\:\\:\\$args \\(array\\\\) does not accept array\\\\|string\\.$#" + count: 1 + path: src/Rector/NewServicesRector.php + + - + message: "#^Property Platformsh\\\\Cli\\\\Rector\\\\NewServicesRector\\:\\:\\$transforms type has no value type specified in iterable type array\\.$#" + count: 1 + path: src/Rector/NewServicesRector.php + + - + message: "#^Access to an undefined property PhpParser\\\\Node\\\\Arg\\|PhpParser\\\\Node\\\\VariadicPlaceholder\\:\\:\\$value\\.$#" + count: 3 + path: src/Rector/UseSelectorServiceRector.php + + - + message: "#^Access to an undefined property PhpParser\\\\Node\\\\Expr\\|PhpParser\\\\Node\\\\Identifier\\:\\:\\$name\\.$#" + count: 6 + path: src/Rector/UseSelectorServiceRector.php + + - + message: "#^Parameter \\#2 \\$callable of method Rector\\\\Rector\\\\AbstractRector\\:\\:traverseNodesWithCallable\\(\\) expects callable\\(PhpParser\\\\Node\\)\\: \\(array\\\\|int\\|PhpParser\\\\Node\\|null\\), Closure\\(PhpParser\\\\NodeAbstract\\)\\: \\(PhpParser\\\\Node\\\\Expr\\\\Assign\\|PhpParser\\\\Node\\\\Expr\\\\MethodCall\\|null\\) given\\.$#" + count: 1 + path: src/Rector/UseSelectorServiceRector.php + + - + message: "#^Ternary operator condition is always false\\.$#" + count: 1 + path: src/Service/ActivityMonitor.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Service\\\\Api\\:\\:checkCanCreate\\(\\) should return array\\{can_create\\: bool, message\\: string, required_action\\: array\\{action\\: string, type\\: string\\}\\|null\\} but returns array\\.$#" + count: 1 + path: src/Service/Api.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Service\\\\Api\\:\\:checkUserVerification\\(\\) should return array\\{state\\: bool, type\\: string\\} but returns array\\.$#" + count: 1 + path: src/Service/Api.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Service\\\\Api\\:\\:getMyAccount\\(\\) should return array\\{id\\: string, username\\: string, email\\: string, first_name\\: string, last_name\\: string, display_name\\: string, phone_number_verified\\: bool\\} but returns non\\-empty\\-array\\.$#" + count: 1 + path: src/Service/Api.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Service\\\\Filesystem\\:\\:remove\\(\\) has parameter \\$files with no value type specified in iterable type iterable\\.$#" + count: 1 + path: src/Service/Filesystem.php + + - + message: "#^Method Platformsh\\\\Cli\\\\Service\\\\Filesystem\\:\\:unprotect\\(\\) has parameter \\$files with no value type specified in iterable type iterable\\.$#" + count: 1 + path: src/Service/Filesystem.php + + - + message: "#^Call to an undefined method GuzzleHttp\\\\ClientInterface\\:\\:head\\(\\)\\.$#" + count: 1 + path: src/Service/Identifier.php + + - + message: "#^Dead catch \\- InvalidArgumentException is never thrown in the try block\\.$#" + count: 1 + path: tests/Command/User/UserAddCommandTest.php + + - + message: "#^Property Platformsh\\\\Cli\\\\Tests\\\\Local\\\\LocalBuildTest\\:\\:\\$localBuild \\(Platformsh\\\\Cli\\\\Local\\\\LocalBuild\\|null\\) does not accept object\\.$#" + count: 1 + path: tests/Local/LocalBuildTest.php + + - + message: "#^Property Platformsh\\\\Cli\\\\Tests\\\\Service\\\\SshTest\\:\\:\\$ssh \\(Platformsh\\\\Cli\\\\Service\\\\Ssh\\|null\\) does not accept object\\.$#" + count: 1 + path: tests/Service/SshTest.php diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000000..3698cb81ff --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,15 @@ +parameters: + level: 7 + paths: + - dist + - resources + - src + - tests + scanFiles: + - config/cache/container.php + - dist/installer.php + excludePaths: + - dist/installer.php + +includes: + - phpstan-baseline.neon diff --git a/phpunit.xml b/phpunit.xml index 232944130b..7341c59ac4 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,13 +1,24 @@ - + - - ./tests/ + + tests - - - ./src/ - - + + + + src + + diff --git a/rector.php b/rector.php new file mode 100644 index 0000000000..deac5e8518 --- /dev/null +++ b/rector.php @@ -0,0 +1,36 @@ +withPaths([ + __DIR__ . '/src', + __DIR__ . '/tests', + ]) + ->withRules([ + CommandConfigureToAttributeRector::class, + InjectCommandServicesRector::class, + UseSelectorServiceRector::class, + NewServicesRector::class, + UnnecessaryServiceVariablesRector::class, + ]) + ->withImportNames(importShortClasses: false, removeUnusedImports: true) + ->withConfiguredRule( + MethodCallToPropertyFetchRector::class, + [new MethodCallToPropertyFetch(CommandBase::class, 'api', 'api')], + ) + ->withConfiguredRule( + MethodCallToPropertyFetchRector::class, + [new MethodCallToPropertyFetch(CommandBase::class, 'config', 'config')], + ) +; diff --git a/resources/oauth-listener/index.php b/resources/oauth-listener/index.php index 32c8e2d169..b2f4294fac 100644 --- a/resources/oauth-listener/index.php +++ b/resources/oauth-listener/index.php @@ -4,26 +4,27 @@ class Listener { - private $state; - private $authUrl; - private $clientId; - private $file; - private $localUrl; - private $response; - private $codeChallenge; - private $prompt; - private $scope; - private $authMethods; - private $maxAge; + private string $state; + private string $authUrl; + private string $clientId; + private string $file; + private string $localUrl; + private Response $response; + private string $codeChallenge; + private string $prompt; + private string $scope; + private string $authMethods; + private ?string $maxAge; - public function __construct() { + public function __construct() + { $required = [ 'CLI_OAUTH_STATE', 'CLI_OAUTH_AUTH_URL', 'CLI_OAUTH_CLIENT_ID', 'CLI_OAUTH_FILE', 'CLI_OAUTH_CODE_CHALLENGE', - 'CLI_OAUTH_PROMPT' + 'CLI_OAUTH_PROMPT', ]; if ($missing = array_diff($required, array_keys($_ENV))) { throw new \RuntimeException('Invalid environment, missing: ' . implode(', ', $missing)); @@ -34,17 +35,17 @@ public function __construct() { $this->file = $_ENV['CLI_OAUTH_FILE']; $this->prompt = $_ENV['CLI_OAUTH_PROMPT']; $this->codeChallenge = $_ENV['CLI_OAUTH_CODE_CHALLENGE']; - $this->scope = isset($_ENV['CLI_OAUTH_SCOPE']) ? $_ENV['CLI_OAUTH_SCOPE'] : ''; + $this->scope = $_ENV['CLI_OAUTH_SCOPE'] ?? ''; $this->localUrl = 'http://127.0.0.1:' . $_SERVER['SERVER_PORT']; $this->response = new Response(); - $this->authMethods = isset($_ENV['CLI_OAUTH_METHODS']) ? $_ENV['CLI_OAUTH_METHODS'] : ''; - $this->maxAge = isset($_ENV['CLI_OAUTH_MAX_AGE']) ? $_ENV['CLI_OAUTH_MAX_AGE'] : null; + $this->authMethods = $_ENV['CLI_OAUTH_METHODS'] ?? ''; + $this->maxAge = $_ENV['CLI_OAUTH_MAX_AGE'] ?? null; } /** * @return string */ - private function getOAuthUrl() + private function getOAuthUrl(): string { $params = [ 'redirect_uri' => $this->localUrl, @@ -70,7 +71,7 @@ private function getOAuthUrl() /** * Check state, run logic, set page content. */ - public function run() + public function run(): void { // Respond after a successful OAuth2 redirect. if (isset($_GET['state'], $_GET['code'])) { @@ -102,8 +103,8 @@ public function run() // Respond after an OAuth2 error. if (isset($_GET['error'])) { - $message = isset($_GET['error_description']) ? $_GET['error_description'] : null; - $hint = isset($_GET['error_hint']) ? $_GET['error_hint'] : null; + $message = $_GET['error_description'] ?? null; + $hint = $_GET['error_hint'] ?? null; $this->reportError($message, $_GET['error'], $hint); return; } @@ -111,46 +112,42 @@ public function run() // In any other case: redirect to login. $url = $this->getOAuthUrl(); $this->setRedirect($url); - $this->response->content = '

Log in.

'; - return; + $this->response->content = '

Log in.

'; } /** * @param string $url - * @param int $code + * @param int $code */ - private function setRedirect($url, $code = 302) + private function setRedirect(string $url, int $code = 302): void { $this->response->code = $code; $this->response->headers['Location'] = $url; } - /** - * @return \Platformsh\Cli\OAuth\Response - */ - public function getResponse() + public function getResponse(): Response { return $this->response; } /** - * @param array $response + * @param array $response * * @return bool */ - private function sendToTerminal(array $response) + private function sendToTerminal(array $response): bool { return (bool) file_put_contents($this->file, json_encode($response), LOCK_EX); } /** - * @param string $message The error message. + * @param string|null $message The error message. * @param string|null $error An OAuth2 error type. * @param string|null $hint An OAuth2 error hint. */ - private function reportError($message = null, $error = null, $hint = null) + private function reportError(?string $message = null, ?string $error = null, ?string $hint = null): void { - $this->response->headers['Status'] = 401; + $this->response->headers['Status'] = '401'; $this->response->title = 'Error'; if (isset($error)) { $this->response->content .= '

' . htmlspecialchars($error) . '

'; @@ -173,11 +170,12 @@ private function reportError($message = null, $error = null, $hint = null) class Response { - public $headers = []; - public $code = 200; - public $headTitle = ''; - public $title = ''; - public $content = ''; + /** @var array */ + public array $headers = []; + public int $code = 200; + public string $headTitle = ''; + public string $title = ''; + public string $content = ''; public function __construct() { diff --git a/resources/phar-stub.php b/resources/phar-stub.php new file mode 100644 index 0000000000..1f0583fe55 --- /dev/null +++ b/resources/phar-stub.php @@ -0,0 +1,5 @@ +#!/usr/bin/env php + diff --git a/resources/router/router.php b/resources/router/router.php index ac4e57c1fa..0d5eebf9d7 100644 --- a/resources/router/router.php +++ b/resources/router/router.php @@ -1,11 +1,14 @@ true]; $matchedLocation = '/'; foreach (array_keys($locations) as $path) { - if (strpos($_SERVER['REQUEST_URI'], $path) === 0) { + if (str_starts_with($_SERVER['REQUEST_URI'], (string) $path)) { $matchedLocation = $path; } elseif (preg_match($pregQuoteNginxPattern($path), $_SERVER['REQUEST_URI'])) { $matchedLocation = $path; @@ -138,7 +141,7 @@ error_log(sprintf( 'Static file "%s" blocked by rule "%s"', $relative_path, - $pattern + $pattern, ), ERROR_LOG_TYPE_SAPI); } } diff --git a/scripts/update-countries.php b/scripts/update-countries.php index 8705dd0f09..f57a380f60 100755 --- a/scripts/update-countries.php +++ b/scripts/update-countries.php @@ -1,5 +1,8 @@ #!/usr/bin/env php getService() method. -# Private services are only used by other services, via dependency injection. -services: - _defaults: - # Services are public by default. - public: true - - # Auto-wiring is disabled for simplicity (at least because the @input - # and @output services will be overridden). - autowire: false - - # Auto-configuring is not necessary. - autoconfigure: false - - activity_loader: - class: '\Platformsh\Cli\Service\ActivityLoader' - arguments: ['@output'] - - activity_monitor: - class: '\Platformsh\Cli\Service\ActivityMonitor' - arguments: ['@output', '@config', '@api'] - - api: - class: '\Platformsh\Cli\Service\Api' - arguments: ['@config', '@cache', '@output', '@token_config', '@file_lock'] - - app_finder: - class: '\Platformsh\Cli\Local\ApplicationFinder' - arguments: ['@config'] - - cache: - class: '\Doctrine\Common\Cache\CacheProvider' - factory: 'cache_factory:createCacheProvider' - arguments: ['@config'] - - cache_factory: - class: '\Platformsh\Cli\Service\CacheFactory' - public: false - - certifier: - class: '\Platformsh\Cli\SshCert\Certifier' - arguments: ['@api', '@config', '@shell', '@fs', '@output', '@file_lock'] - - config: - class: '\Platformsh\Cli\Service\Config' - - curl_cli: - class: '\Platformsh\Cli\Service\CurlCli' - arguments: ['@api'] - - drush: - class: '\Platformsh\Cli\Service\Drush' - arguments: ['@config', '@shell', '@local.project', '@api', '@app_finder'] - - file_lock: - class: '\Platformsh\Cli\Service\FileLock' - arguments: ['@config'] - - fs: - class: '\Platformsh\Cli\Service\Filesystem' - arguments: ['@shell'] - - git: - class: '\Platformsh\Cli\Service\Git' - arguments: ['@shell', '@ssh'] - - git_data_api: - class: '\Platformsh\Cli\Service\GitDataApi' - arguments: ['@api', '@cache'] - - identifier: - class: '\Platformsh\Cli\Service\Identifier' - arguments: ['@config', '@api', '@output', '@cache'] - - # This is a placeholder that will be overridden in the command invocation. - input: - class: '\Symfony\Component\Console\Input\ArrayInput' - public: false - arguments: [[]] - - local.build: - class: '\Platformsh\Cli\Local\LocalBuild' - arguments: ['@config', '@output', '@shell', '@fs', '@git', '@local.dependency_installer', '@app_finder'] - - local.dependency_installer: - class: '\Platformsh\Cli\Local\DependencyInstaller' - arguments: ['@output', '@shell'] - public: false - - local.project: - class: '\Platformsh\Cli\Local\LocalProject' - arguments: ['@config', '@git'] - - mount: - class: '\Platformsh\Cli\Service\Mount' - - # This is a placeholder that will be overridden in the command invocation. - output: - class: '\Symfony\Component\Console\Output\ConsoleOutput' - public: false - - property_formatter: - class: '\Platformsh\Cli\Service\PropertyFormatter' - arguments: ['@input'] - - question_helper: - class: '\Platformsh\Cli\Service\QuestionHelper' - arguments: ['@input', '@output'] - - remote_env_vars: - class: '\Platformsh\Cli\Service\RemoteEnvVars' - arguments: ['@ssh', '@cache', '@config'] - public: false - - relationships: - class: '\Platformsh\Cli\Service\Relationships' - arguments: ['@remote_env_vars'] - - rsync: - class: '\Platformsh\Cli\Service\Rsync' - arguments: ['@shell', '@ssh', '@ssh_diagnostics'] - - self_updater: - class: '\Platformsh\Cli\Service\SelfUpdater' - arguments: ['@input', '@output', '@config', '@question_helper'] - - shell: - class: '\Platformsh\Cli\Service\Shell' - arguments: ['@output'] - - ssh: - class: '\Platformsh\Cli\Service\Ssh' - arguments: ['@input', '@output', '@config', '@certifier', '@ssh_config', '@ssh_key'] - - ssh_config: - class: '\Platformsh\Cli\Service\SshConfig' - arguments: ['@config', '@fs', '@output', '@ssh_key', '@certifier'] - - ssh_diagnostics: - class: '\Platformsh\Cli\Service\SshDiagnostics' - arguments: ['@ssh', '@output', '@certifier', '@ssh_key', '@api', '@config'] - - ssh_key: - class: '\Platformsh\Cli\Service\SshKey' - arguments: ['@config', '@api', '@output'] - - state: - class: '\Platformsh\Cli\Service\State' - arguments: ['@config'] - - table: - class: '\Platformsh\Cli\Service\Table' - arguments: ['@input', '@output'] - - token_config: - class: '\Platformsh\Cli\Service\TokenConfig' - arguments: ['@config'] - - url: - class: '\Platformsh\Cli\Service\Url' - arguments: ['@shell', '@input', '@output'] diff --git a/src/ApiToken/CredentialHelperStorage.php b/src/ApiToken/CredentialHelperStorage.php index 530bc17c64..eadb06fd0c 100644 --- a/src/ApiToken/CredentialHelperStorage.php +++ b/src/ApiToken/CredentialHelperStorage.php @@ -1,5 +1,7 @@ manager = $manager; $this->serverUrl = sprintf( '%s/%s/api-token', - $config->get('application.slug'), - $config->getSessionId() + $config->getStr('application.slug'), + $config->getSessionId(), ); } /** * @inheritDoc */ - public function getToken() + public function getToken(): string { return $this->manager->get($this->serverUrl) ?: ''; } @@ -33,7 +34,7 @@ public function getToken() /** * @inheritDoc */ - public function storeToken($value) + public function storeToken($value): void { $this->manager->store($this->serverUrl, $value); } @@ -41,7 +42,7 @@ public function storeToken($value) /** * @inheritDoc */ - public function deleteToken() + public function deleteToken(): void { $this->manager->erase($this->serverUrl); } diff --git a/src/ApiToken/FileStorage.php b/src/ApiToken/FileStorage.php index 687db6629e..8d75edcc62 100644 --- a/src/ApiToken/FileStorage.php +++ b/src/ApiToken/FileStorage.php @@ -1,5 +1,7 @@ config = $config; $this->fs = $fs ?: new SymfonyFilesystem(); } @@ -23,7 +24,8 @@ public function __construct(Config $config, SymfonyFilesystem $fs = null) * * @return string */ - public function getToken() { + public function getToken(): string + { return $this->load(); } @@ -32,21 +34,21 @@ public function getToken() { * * @param string $value */ - public function storeToken($value) { + public function storeToken(string $value): void + { $this->save($value); } /** * Deletes the saved token. */ - public function deleteToken() { + public function deleteToken(): void + { $this->save(''); } - /** - * @param string $token - */ - private function save($token) { + private function save(string $token): void + { $filename = $this->getFilename(); if (empty($token)) { if (file_exists($filename)) { @@ -56,18 +58,19 @@ private function save($token) { } // Avoid overwriting an already configured token file. - if (file_exists($filename) && $this->config->has('api.token_file') && $this->resolveTokenFile($this->config->get('api.token_file')) === $filename) { + if (file_exists($filename) && $this->config->has('api.token_file') && $this->resolveTokenFile($this->config->getStr('api.token_file')) === $filename) { throw new \RuntimeException('Failed to save API token: it would conflict with the existing api.token_file configuration.'); } $this->fs->dumpFile($filename, $token); - $this->fs->chmod($filename, 0600); + $this->fs->chmod($filename, 0o600); } /** * @return string */ - private function load() { + private function load(): string + { $filename = $this->getFilename(); if (file_exists($filename)) { return trim((string) file_get_contents($filename)); @@ -79,19 +82,17 @@ private function load() { /** * @return string */ - private function getFilename() { + private function getFilename(): string + { return $this->config->getSessionDir(true) . DIRECTORY_SEPARATOR . 'api-token'; } /** * Makes a relative path absolute, based on the user config dir. - * - * @param string $tokenFile - * - * @return string */ - private function resolveTokenFile($tokenFile) { - if (strpos($tokenFile, '/') !== 0 && strpos($tokenFile, '\\') !== 0) { + private function resolveTokenFile(string $tokenFile): string + { + if (!str_starts_with($tokenFile, '/') && !str_starts_with($tokenFile, '\\')) { $tokenFile = $this->config->getUserConfigDir() . '/' . $tokenFile; } diff --git a/src/ApiToken/Storage.php b/src/ApiToken/Storage.php index e4c6fbf61a..7cdd9878bf 100644 --- a/src/ApiToken/Storage.php +++ b/src/ApiToken/Storage.php @@ -1,5 +1,7 @@ isSupported()) { return new CredentialHelperStorage($config, $manager); diff --git a/src/ApiToken/StorageInterface.php b/src/ApiToken/StorageInterface.php index 98c300be5c..e406710367 100644 --- a/src/ApiToken/StorageInterface.php +++ b/src/ApiToken/StorageInterface.php @@ -1,24 +1,25 @@ cliConfig = new Config(); - $this->envPrefix = $this->cliConfig->get('application.env_prefix'); - parent::__construct($this->cliConfig->get('application.name'), $this->cliConfig->getVersion()); + // Initialize configuration (from config.yaml). + $this->config = $config ?: new Config(); + $this->envPrefix = $this->config->getStr('application.env_prefix'); + parent::__construct($this->config->getStr('application.name'), $this->config->getVersion()); // Use the configured timezone, or fall back to the system timezone. date_default_timezone_set( - $this->cliConfig->getWithDefault('application.timezone', TimezoneUtil::getTimezone()) + $this->config->getWithDefault('application.timezone', null) + ?: TimezoneUtil::getTimezone(), ); - $this->addCommands($this->getCommands()); + // Set the Config service. + $this->container()->set(Config::class, $this->config); - $this->setDefaultCommand('welcome'); + // Set up the command loader, which will load commands that are tagged + // appropriately in the services.yaml container configuration (any + // services tagged with "console.command"). + /** @var CommandLoaderInterface $loader */ + $loader = $this->container()->get('console.command_loader'); + $this->setCommandLoader($loader); + // Set "welcome" as the default command. + $this->setDefaultCommand(WelcomeCommand::getDefaultName()); + + // Set up an event subscriber, which will listen for Console events. $dispatcher = new EventDispatcher(); - $dispatcher->addSubscriber(new EventSubscriber($this->cliConfig)); + /** @var CacheProvider $cache */ + $cache = $this->container()->get(CacheProvider::class); + $dispatcher->addSubscriber(new EventSubscriber($cache, $this->config)); $this->setDispatcher($dispatcher); } + /** + * {@inheritDoc} + */ + public function getVersion(): string + { + return $this->config->getVersion(); + } + + /** + * Re-compile the container and other caches. + */ + public static function warmCaches(): void + { + require_once dirname(__DIR__) . '/constants.php'; + $a = new self(); + $a->container(true); + } + /** * {@inheritdoc} + * + * Prevent commands being enabled, according to config.yaml configuration. */ - protected function getDefaultInputDefinition() + public function add(ConsoleCommand $command): ?ConsoleCommand + { + if (!$this->config->isCommandEnabled($command->getName())) { + $command->setApplication(null); + return null; + } + + return parent::add($command); + } + + /** + * Returns the Dependency Injection Container for the whole application. + * + * @param bool $recompile + * + * @return ContainerInterface + */ + private function container(bool $recompile = false): ContainerInterface + { + $cacheFile = __DIR__ . '/../config/cache/container.php'; + $servicesFile = __DIR__ . '/../config/services.yaml'; + + if (!isset($this->container)) { + if (file_exists($cacheFile) && !$recompile) { + // Load the cached container. + require_once $cacheFile; + /** @noinspection PhpUndefinedClassInspection */ + /** @noinspection PhpFieldAssignmentTypeMismatchInspection */ + $this->container = new \ProjectServiceContainer(); + } else { + // Compile a new container. + $this->container = new ContainerBuilder(); + try { + (new YamlFileLoader($this->container, new FileLocator())) + ->load($servicesFile); + } catch (\Exception $e) { + throw new \RuntimeException(sprintf( + 'Failed to load services.yaml file %s: %s', + $servicesFile, + $e->getMessage(), + )); + } + $this->container->addCompilerPass(new AddConsoleCommandPass()); + $this->container->compile(); + $dumper = new PhpDumper($this->container); + if (!is_dir(dirname($cacheFile))) { + mkdir(dirname($cacheFile), 0o755, true); + } + file_put_contents($cacheFile, $dumper->dump()); + } + } + + return $this->container; + } + + /** + * {@inheritdoc} + */ + protected function getDefaultInputDefinition(): InputDefinition { return new InputDefinition([ new InputArgument('command', InputArgument::REQUIRED, 'The command to execute'), @@ -77,7 +175,7 @@ protected function getDefaultInputDefinition() null, InputOption::VALUE_NONE, 'Do not ask any interactive questions; accept default values. ' - . sprintf('Equivalent to using the environment variable: %sNO_INTERACTION=1', $this->envPrefix) + . sprintf('Equivalent to using the environment variable: %sNO_INTERACTION=1', $this->envPrefix), ), new HiddenInputOption('--ansi', '', InputOption::VALUE_NONE, 'Force ANSI output'), new HiddenInputOption('--no-ansi', '', InputOption::VALUE_NONE, 'Disable ANSI output'), @@ -88,214 +186,20 @@ protected function getDefaultInputDefinition() /** * @inheritdoc */ - protected function getDefaultCommands() - { - // Override the default commands to add a custom HelpCommand and - // ListCommand. - return [new Command\HelpCommand(), new Command\ListCommand()]; - } - - /** - * @return \Symfony\Component\Console\Command\Command[] - */ - protected function getCommands() + protected function getDefaultCommands(): array { - static $commands = []; - if (count($commands)) { - return $commands; - } - - $commands[] = new Command\ApiCurlCommand(); - $commands[] = new Command\BotCommand(); - $commands[] = new Command\ClearCacheCommand(); - $commands[] = new Command\CompletionCommand(); - $commands[] = new Command\DecodeCommand(); - $commands[] = new Command\DocsCommand(); - $commands[] = new Command\LegacyMigrateCommand(); - $commands[] = new Command\MultiCommand(); - $commands[] = new Command\Activity\ActivityCancelCommand(); - $commands[] = new Command\Activity\ActivityGetCommand(); - $commands[] = new Command\Activity\ActivityListCommand(); - $commands[] = new Command\Activity\ActivityLogCommand(); - $commands[] = new Command\App\AppConfigGetCommand(); - $commands[] = new Command\App\AppListCommand(); - $commands[] = new Command\Auth\AuthInfoCommand(); - $commands[] = new Command\Auth\AuthTokenCommand(); - $commands[] = new Command\Auth\LogoutCommand(); - $commands[] = new Command\Auth\ApiTokenLoginCommand(); - $commands[] = new Command\Auth\BrowserLoginCommand(); - $commands[] = new Command\Auth\VerifyPhoneNumberCommand(); - $commands[] = new Command\BlueGreen\BlueGreenConcludeCommand(); - $commands[] = new Command\BlueGreen\BlueGreenDeployCommand(); - $commands[] = new Command\BlueGreen\BlueGreenEnableCommand(); - $commands[] = new Command\Certificate\CertificateAddCommand(); - $commands[] = new Command\Certificate\CertificateDeleteCommand(); - $commands[] = new Command\Certificate\CertificateGetCommand(); - $commands[] = new Command\Certificate\CertificateListCommand(); - $commands[] = new Command\Commit\CommitGetCommand(); - $commands[] = new Command\Commit\CommitListCommand(); - $commands[] = new Command\Db\DbSqlCommand(); - $commands[] = new Command\Db\DbDumpCommand(); - $commands[] = new Command\Db\DbSizeCommand(); - $commands[] = new Command\Domain\DomainAddCommand(); - $commands[] = new Command\Domain\DomainDeleteCommand(); - $commands[] = new Command\Domain\DomainGetCommand(); - $commands[] = new Command\Domain\DomainListCommand(); - $commands[] = new Command\Domain\DomainUpdateCommand(); - $commands[] = new Command\Environment\EnvironmentActivateCommand(); - $commands[] = new Command\Environment\EnvironmentBranchCommand(); - $commands[] = new Command\Environment\EnvironmentCheckoutCommand(); - $commands[] = new Command\Environment\EnvironmentCurlCommand(); - $commands[] = new Command\Environment\EnvironmentDeleteCommand(); - $commands[] = new Command\Environment\EnvironmentDrushCommand(); - $commands[] = new Command\Environment\EnvironmentHttpAccessCommand(); - $commands[] = new Command\Environment\EnvironmentListCommand(); - $commands[] = new Command\Environment\EnvironmentLogCommand(); - $commands[] = new Command\Environment\EnvironmentInfoCommand(); - $commands[] = new Command\Environment\EnvironmentInitCommand(); - $commands[] = new Command\Environment\EnvironmentMergeCommand(); - $commands[] = new Command\Environment\EnvironmentPauseCommand(); - $commands[] = new Command\Environment\EnvironmentPushCommand(); - $commands[] = new Command\Environment\EnvironmentRedeployCommand(); - $commands[] = new Command\Environment\EnvironmentRelationshipsCommand(); - $commands[] = new Command\Environment\EnvironmentResumeCommand(); - $commands[] = new Command\Environment\EnvironmentSshCommand(); - $commands[] = new Command\Environment\EnvironmentScpCommand(); - $commands[] = new Command\Environment\EnvironmentSynchronizeCommand(); - $commands[] = new Command\Environment\EnvironmentUrlCommand(); - $commands[] = new Command\Environment\EnvironmentSetRemoteCommand(); - $commands[] = new Command\Environment\EnvironmentXdebugCommand(); - $commands[] = new Command\Integration\IntegrationAddCommand(); - $commands[] = new Command\Integration\IntegrationDeleteCommand(); - $commands[] = new Command\Integration\IntegrationGetCommand(); - $commands[] = new Command\Integration\IntegrationListCommand(); - $commands[] = new Command\Integration\IntegrationUpdateCommand(); - $commands[] = new Command\Integration\IntegrationValidateCommand(); - $commands[] = new Command\Integration\Activity\IntegrationActivityGetCommand(); - $commands[] = new Command\Integration\Activity\IntegrationActivityListCommand(); - $commands[] = new Command\Integration\Activity\IntegrationActivityLogCommand(); - $commands[] = new Command\Local\LocalBuildCommand(); - $commands[] = new Command\Local\LocalCleanCommand(); - $commands[] = new Command\Local\LocalDrushAliasesCommand(); - $commands[] = new Command\Local\LocalDirCommand(); - $commands[] = new Command\Mount\MountListCommand(); - $commands[] = new Command\Mount\MountDownloadCommand(); - $commands[] = new Command\Mount\MountSizeCommand(); - $commands[] = new Command\Mount\MountUploadCommand(); - $commands[] = new Command\Organization\OrganizationCreateCommand(); - $commands[] = new Command\Organization\OrganizationCurlCommand(); - $commands[] = new Command\Organization\OrganizationDeleteCommand(); - $commands[] = new Command\Organization\OrganizationInfoCommand(); - $commands[] = new Command\Organization\OrganizationListCommand(); - $commands[] = new Command\Organization\OrganizationSubscriptionListCommand(); - $commands[] = new Command\Organization\Billing\OrganizationAddressCommand(); - $commands[] = new Command\Organization\Billing\OrganizationProfileCommand(); - $commands[] = new Command\Organization\User\OrganizationUserAddCommand(); - $commands[] = new Command\Organization\User\OrganizationUserDeleteCommand(); - $commands[] = new Command\Organization\User\OrganizationUserGetCommand(); - $commands[] = new Command\Organization\User\OrganizationUserListCommand(); - $commands[] = new Command\Organization\User\OrganizationUserProjectsCommand(); - $commands[] = new Command\Organization\User\OrganizationUserUpdateCommand(); - $commands[] = new Command\Metrics\AllMetricsCommand(); - $commands[] = new Command\Metrics\CpuCommand(); - $commands[] = new Command\Metrics\CurlCommand(); - $commands[] = new Command\Metrics\DiskUsageCommand(); - $commands[] = new Command\Metrics\MemCommand(); - $commands[] = new Command\Project\ProjectClearBuildCacheCommand(); - $commands[] = new Command\Project\ProjectCurlCommand(); - $commands[] = new Command\Project\ProjectCreateCommand(); - $commands[] = new Command\Project\ProjectDeleteCommand(); - $commands[] = new Command\Project\ProjectGetCommand(); - $commands[] = new Command\Project\ProjectListCommand(); - $commands[] = new Command\Project\ProjectInfoCommand(); - $commands[] = new Command\Project\ProjectSetRemoteCommand(); - $commands[] = new Command\Project\Variable\ProjectVariableDeleteCommand(); - $commands[] = new Command\Project\Variable\ProjectVariableGetCommand(); - $commands[] = new Command\Project\Variable\ProjectVariableSetCommand(); - $commands[] = new Command\Repo\CatCommand(); - $commands[] = new Command\Repo\LsCommand(); - $commands[] = new Command\Repo\ReadCommand(); - $commands[] = new Command\Route\RouteListCommand(); - $commands[] = new Command\Route\RouteGetCommand(); - $commands[] = new Command\Self\SelfBuildCommand(); - $commands[] = new Command\Self\SelfConfigCommand(); - $commands[] = new Command\Self\SelfInstallCommand(); - $commands[] = new Command\Self\SelfUpdateCommand(); - $commands[] = new Command\Self\SelfReleaseCommand(); - $commands[] = new Command\Self\SelfStatsCommand(); - $commands[] = new Command\Server\ServerRunCommand(); - $commands[] = new Command\Server\ServerStartCommand(); - $commands[] = new Command\Server\ServerListCommand(); - $commands[] = new Command\Server\ServerStopCommand(); - $commands[] = new Command\Service\MongoDB\MongoDumpCommand(); - $commands[] = new Command\Service\MongoDB\MongoExportCommand(); - $commands[] = new Command\Service\MongoDB\MongoRestoreCommand(); - $commands[] = new Command\Service\MongoDB\MongoShellCommand(); - $commands[] = new Command\Service\RedisCliCommand(); - $commands[] = new Command\Service\ServiceListCommand(); - $commands[] = new Command\Session\SessionSwitchCommand(); - $commands[] = new Command\Backup\BackupCreateCommand(); - $commands[] = new Command\Backup\BackupDeleteCommand(); - $commands[] = new Command\Backup\BackupGetCommand(); - $commands[] = new Command\Backup\BackupListCommand(); - $commands[] = new Command\Backup\BackupRestoreCommand(); - $commands[] = new Command\Resources\ResourcesGetCommand(); - $commands[] = new Command\Resources\ResourcesSizeListCommand(); - $commands[] = new Command\Resources\ResourcesSetCommand(); - $commands[] = new Command\Resources\Build\BuildResourcesGetCommand(); - $commands[] = new Command\Resources\Build\BuildResourcesSetCommand(); - $commands[] = new Command\RuntimeOperation\ListCommand(); - $commands[] = new Command\RuntimeOperation\RunCommand(); - $commands[] = new Command\SourceOperation\ListCommand(); - $commands[] = new Command\SourceOperation\RunCommand(); - $commands[] = new Command\SshCert\SshCertInfoCommand(); - $commands[] = new Command\SshCert\SshCertLoadCommand(); - $commands[] = new Command\SshKey\SshKeyAddCommand(); - $commands[] = new Command\SshKey\SshKeyDeleteCommand(); - $commands[] = new Command\SshKey\SshKeyListCommand(); - $commands[] = new Command\SubscriptionInfoCommand(); - $commands[] = new Command\Team\TeamCreateCommand(); - $commands[] = new Command\Team\TeamDeleteCommand(); - $commands[] = new Command\Team\TeamGetCommand(); - $commands[] = new Command\Team\TeamListCommand(); - $commands[] = new Command\Team\TeamUpdateCommand(); - $commands[] = new Command\Team\Project\TeamProjectAddCommand(); - $commands[] = new Command\Team\Project\TeamProjectDeleteCommand(); - $commands[] = new Command\Team\Project\TeamProjectListCommand(); - $commands[] = new Command\Team\User\TeamUserAddCommand(); - $commands[] = new Command\Team\User\TeamUserDeleteCommand(); - $commands[] = new Command\Team\User\TeamUserListCommand(); - $commands[] = new Command\Tunnel\TunnelCloseCommand(); - $commands[] = new Command\Tunnel\TunnelInfoCommand(); - $commands[] = new Command\Tunnel\TunnelListCommand(); - $commands[] = new Command\Tunnel\TunnelOpenCommand(); - $commands[] = new Command\Tunnel\TunnelSingleCommand(); - $commands[] = new Command\User\UserAddCommand(); - $commands[] = new Command\User\UserDeleteCommand(); - $commands[] = new Command\User\UserListCommand(); - $commands[] = new Command\User\UserGetCommand(); - $commands[] = new Command\User\UserUpdateCommand(); - $commands[] = new Command\Variable\VariableCreateCommand(); - $commands[] = new Command\Variable\VariableDeleteCommand(); - $commands[] = new Command\Variable\VariableDisableCommand(); - $commands[] = new Command\Variable\VariableEnableCommand(); - $commands[] = new Command\Variable\VariableGetCommand(); - $commands[] = new Command\Variable\VariableListCommand(); - $commands[] = new Command\Variable\VariableSetCommand(); - $commands[] = new Command\Variable\VariableUpdateCommand(); - $commands[] = new Command\Version\VersionListCommand(); - $commands[] = new Command\WelcomeCommand(); - $commands[] = new Command\WebCommand(); - $commands[] = new Command\WinkyCommand(); - $commands[] = new Command\Worker\WorkerListCommand(); - - return $commands; + return [ + new HelpCommand($this->config), + new ListCommand($this->config), + new CompleteCommand(), + new DumpCompletionCommand(), + ]; } /** * @inheritdoc */ - public function getHelp() + public function getHelp(): string { $messages = [ $this->getLongVersion(), @@ -309,18 +213,37 @@ public function getHelp() ' %-29s %s %s', '--' . $option->getName() . '', $option->getShortcut() ? '-' . $option->getShortcut() . '' : ' ', - $option->getDescription() + $option->getDescription(), ); } return implode(PHP_EOL, $messages); } + /** + * @internal + */ + public function setIO(InputInterface $input, OutputInterface $output): void + { + $this->container()->set(InputInterface::class, $input); + $this->container()->set(OutputInterface::class, $output); + } + /** * {@inheritdoc} */ - protected function configureIO(InputInterface $input, OutputInterface $output) + protected function configureIO(InputInterface $input, OutputInterface $output): void { + $this->setIO($input, $output); + + // Set the input to non-interactive if the yes or no options are used, + // or if the PLATFORMSH_CLI_NO_INTERACTION variable is not empty. + // The --no-interaction option is handled in the parent method. + if ($input->hasParameterOption(['--yes', '-y', '--no', '-n']) + || getenv($this->envPrefix . 'NO_INTERACTION')) { + $input->setInteractive(false); + } + // Allow the NO_COLOR, CLICOLOR_FORCE, and TERM environment variables to // override whether colors are used in the output. // See: https://no-color.org @@ -352,49 +275,59 @@ protected function configureIO(InputInterface $input, OutputInterface $output) if ($input->hasParameterOption('-n', true)) { $stdErr->writeln('DEPRECATED The -n flag (as a shortcut for --no) is deprecated. It will be removed or changed in a future version.'); } - } elseif (\function_exists('posix_isatty')) { - $inputStream = null; - - if ($input instanceof StreamableInputInterface) { - $inputStream = $input->getStream(); - } - - if (!@posix_isatty($inputStream) && false === getenv('SHELL_INTERACTIVE')) { + } elseif (\function_exists('posix_isatty') && $input instanceof ArgvInput && defined('STDIN')) { + if (!@posix_isatty(STDIN) && false === getenv('SHELL_INTERACTIVE')) { $input->setInteractive(false); } } switch ($shellVerbosity = (int) getenv('SHELL_VERBOSITY')) { - case -1: $stdErr->setVerbosity(OutputInterface::VERBOSITY_QUIET); break; - case 1: $output->setVerbosity(OutputInterface::VERBOSITY_VERBOSE); break; - case 2: $output->setVerbosity(OutputInterface::VERBOSITY_VERY_VERBOSE); break; - case 3: $output->setVerbosity(OutputInterface::VERBOSITY_DEBUG); break; - default: $shellVerbosity = 0; break; + case -2: $output->setVerbosity(OutputInterface::VERBOSITY_SILENT); + break; + case -1: $stdErr->setVerbosity(OutputInterface::VERBOSITY_QUIET); + break; + case 1: $output->setVerbosity(OutputInterface::VERBOSITY_VERBOSE); + break; + case 2: $output->setVerbosity(OutputInterface::VERBOSITY_VERY_VERBOSE); + break; + case 3: $output->setVerbosity(OutputInterface::VERBOSITY_DEBUG); + break; + default: $shellVerbosity = 0; + break; } - if ($input->hasParameterOption('-vvv', true) - || getenv('CLI_DEBUG') || getenv($this->envPrefix . 'DEBUG')) { - $output->setVerbosity(OutputInterface::VERBOSITY_DEBUG); - $shellVerbosity = 3; - } elseif ($input->hasParameterOption('-vv', true)) { - $output->setVerbosity(OutputInterface::VERBOSITY_VERY_VERBOSE); - $shellVerbosity = 2; - } elseif ($input->hasParameterOption(['-v', '--verbose'], true)) { - $output->setVerbosity(OutputInterface::VERBOSITY_VERBOSE); - $shellVerbosity = 1; - } elseif ($input->hasParameterOption(['--quiet', '-q'], true)) { + if (true === $input->hasParameterOption(['--silent'], true)) { + $output->setVerbosity(OutputInterface::VERBOSITY_SILENT); + $shellVerbosity = -2; + } elseif (true === $input->hasParameterOption(['--quiet', '-q'], true)) { $stdErr->setVerbosity(OutputInterface::VERBOSITY_QUIET); - $input->setInteractive(false); $shellVerbosity = -1; + } else { + if ($input->hasParameterOption('-vvv', true) || $input->hasParameterOption('--verbose=3', true) || 3 === $input->getParameterOption('--verbose', false, true)) { + $output->setVerbosity(OutputInterface::VERBOSITY_DEBUG); + $shellVerbosity = 3; + } elseif ($input->hasParameterOption('-vv', true) || $input->hasParameterOption('--verbose=2', true) || 2 === $input->getParameterOption('--verbose', false, true)) { + $output->setVerbosity(OutputInterface::VERBOSITY_VERY_VERBOSE); + $shellVerbosity = 2; + } elseif ($input->hasParameterOption('-v', true) || $input->hasParameterOption('--verbose=1', true) || $input->hasParameterOption('--verbose', true) || $input->getParameterOption('--verbose', false, true)) { + $output->setVerbosity(OutputInterface::VERBOSITY_VERBOSE); + $shellVerbosity = 1; + } } - putenv('SHELL_VERBOSITY='.$shellVerbosity); + if (0 > $shellVerbosity) { + $input->setInteractive(false); + } + + if (\function_exists('putenv')) { + @putenv('SHELL_VERBOSITY=' . $shellVerbosity); + } $_ENV['SHELL_VERBOSITY'] = $shellVerbosity; $_SERVER['SHELL_VERBOSITY'] = $shellVerbosity; // Turn off error reporting in quiet mode. if ($shellVerbosity === -1) { - error_reporting(false); + error_reporting(0); ini_set('display_errors', '0'); } else { // Display errors by default. In verbose mode, display all PHP @@ -414,121 +347,50 @@ protected function configureIO(InputInterface $input, OutputInterface $output) /** * {@inheritdoc} */ - protected function doRunCommand(ConsoleCommand $command, InputInterface $input, OutputInterface $output) + protected function doRunCommand(ConsoleCommand $command, InputInterface $input, OutputInterface $output): int { - $this->setCurrentCommand($command); + if (!$command->isEnabled()) { + throw new \InvalidArgumentException(sprintf('The command "%s" is not enabled.', $command->getName())); + } + if ($command instanceof MultiAwareInterface) { $command->setRunningViaMulti($this->runningViaMulti); } - // Build the command synopsis early, so it doesn't include default - // options and arguments (such as --help and ). - // @todo find a better solution for this? - $this->currentCommand->getSynopsis(); - - return parent::doRunCommand($command, $input, $output); - } - - /** - * Set the current command. This is used for error handling. - * - * @param ConsoleCommand|null $command - */ - public function setCurrentCommand(ConsoleCommand $command = null) - { - // The parent class has a similar (private) property named - // $runningCommand. - $this->currentCommand = $command; - } - - /** - * {@inheritdoc} - */ - public function renderException(\Exception $e, OutputInterface $output) - { - $output->writeln('', OutputInterface::VERBOSITY_QUIET); - $main = $e; - - do { - $exceptionName = get_class($e); - if (($pos = strrpos($exceptionName, '\\')) !== false) { - $exceptionName = substr($exceptionName, $pos + 1); - } - $title = sprintf(' [%s] ', $exceptionName); - - $len = strlen($title); - - $width = (new Terminal())->getWidth() - 1; - $formatter = $output->getFormatter(); - $lines = array(); - foreach (preg_split('/\r?\n/', $e->getMessage()) as $line) { - foreach (str_split($line, $width - 4) as $chunk) { - // pre-format lines to get the right string length - $lineLength = strlen(preg_replace('/\[[^m]*m/', '', $formatter->format($chunk))) + 4; - $lines[] = array($chunk, $lineLength); + // Work around a bug in Console which means the default command's input + // is always considered to be interactive. + if ($command->getName() === 'welcome' + && isset($GLOBALS['argv']) + && array_intersect($GLOBALS['argv'], ['-n', '--no', '-y', '---yes'])) { + $input->setInteractive(false); + } - $len = max($lineLength, $len); - } - } + // Check for automatic updates. + $noChecks = $command->getName() == '_completion'; + $container = $this->container(); + if ($input->isInteractive() && !$noChecks) { + /** @var SelfUpdateChecker $checker */ + $checker = $container->get(SelfUpdateChecker::class); + $checker->checkUpdates(); + } - $messages = array(); - $messages[] = $emptyLine = $formatter->format(sprintf('%s', str_repeat(' ', $len))); - $messages[] = $formatter->format(sprintf('%s%s', $title, str_repeat(' ', max(0, $len - strlen($title))))); - foreach ($lines as $line) { - $messages[] = $formatter->format(sprintf(' %s %s', $line[0], str_repeat(' ', $len - $line[1]))); - } - $messages[] = $emptyLine; - $messages[] = ''; - - $output->writeln($messages, OutputInterface::OUTPUT_RAW | OutputInterface::VERBOSITY_QUIET); - - if ($output->isDebug()) { - $output->writeln('Exception trace:', OutputInterface::VERBOSITY_QUIET); - - // exception related properties - $trace = $e->getTrace(); - array_unshift($trace, array( - 'function' => '', - 'file' => $e->getFile() !== null ? $e->getFile() : 'n/a', - 'line' => $e->getLine() !== null ? $e->getLine() : 'n/a', - 'args' => array(), - )); - - for ($i = 0, $count = count($trace); $i < $count; ++$i) { - $class = isset($trace[$i]['class']) ? $trace[$i]['class'] : ''; - $type = isset($trace[$i]['type']) ? $trace[$i]['type'] : ''; - $function = $trace[$i]['function']; - $file = isset($trace[$i]['file']) ? $trace[$i]['file'] : 'n/a'; - $line = isset($trace[$i]['line']) ? $trace[$i]['line'] : 'n/a'; - - $output->writeln(sprintf(' %s%s%s() at %s:%s', $class, $type, $function, $file, $line), OutputInterface::VERBOSITY_QUIET); - } + if (!$noChecks && $command->getName() !== 'legacy-migrate') { + /** @var LegacyMigration $legacyMigration */ + $legacyMigration = $container->get(LegacyMigration::class); + $legacyMigration->checkMigrateFrom3xTo4x(); + $legacyMigration->checkMigrateToGoWrapper(); + } - $output->writeln('', OutputInterface::VERBOSITY_QUIET); - } - } while (($c = $e) && ($e = $e->getPrevious()) && $e->getMessage() !== $c->getMessage()); - - if (isset($this->currentCommand) - && $this->currentCommand->getName() !== 'welcome' - && ($main instanceof ConsoleInvalidArgumentException - || $main instanceof ConsoleInvalidOptionException - || $main instanceof ConsoleRuntimeException - )) { - $output->writeln( - sprintf('Usage: %s', $this->currentCommand->getSynopsis()), - OutputInterface::VERBOSITY_QUIET - ); - $output->writeln('', OutputInterface::VERBOSITY_QUIET); - $output->writeln(sprintf( - 'For more information, type: %s help %s', - $this->cliConfig->get('application.executable'), - $this->currentCommand->getName() - ), OutputInterface::VERBOSITY_QUIET); - $output->writeln('', OutputInterface::VERBOSITY_QUIET); + if (!$noChecks && $command->getName() !== 'self::install') { + /** @var SelfInstallChecker $selfInstallChecker */ + $selfInstallChecker = $container->get(SelfInstallChecker::class); + $selfInstallChecker->checkSelfInstall(); } + + return parent::doRunCommand($command, $input, $output); } - public function setRunningViaMulti() + public function setRunningViaMulti(): void { $this->runningViaMulti = true; } diff --git a/src/Command/Activity/ActivityCancelCommand.php b/src/Command/Activity/ActivityCancelCommand.php index f40693acf5..a697a731c1 100644 --- a/src/Command/Activity/ActivityCancelCommand.php +++ b/src/Command/Activity/ActivityCancelCommand.php @@ -1,6 +1,13 @@ setName('activity:cancel') - ->setDescription('Cancel an activity') ->addArgument('id', InputArgument::OPTIONAL, 'The activity ID. Defaults to the most recent cancellable activity.') - ->addOption('type', 't', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + ->addOption( + 'type', + 't', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter by type (when selecting a default activity).' . "\n" . ArrayArgument::SPLIT_HELP - . "\nThe % or * characters can be used as a wildcard for the type, e.g. '%var%' to select variable-related activities." + . "\nThe % or * characters can be used as a wildcard for the type, e.g. '%var%' to select variable-related activities.", + null, + ActivityLoader::getAvailableTypes(), ) - ->addOption('exclude-type', 'x', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + ->addOption( + 'exclude-type', + 'x', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Exclude by type (when selecting a default activity).' . "\n" . ArrayArgument::SPLIT_HELP - . "\nThe % or * characters can be used as a wildcard to exclude types." + . "\nThe % or * characters can be used as a wildcard to exclude types.", + null, + ActivityLoader::getAvailableTypes(), ) ->addOption('all', 'a', InputOption::VALUE_NONE, 'Check recent activities on all environments (when selecting a default activity)'); - $this->addProjectOption() - ->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input, $input->getOption('all') || $input->getArgument('id')); - - /** @var ActivityLoader $loader */ - $loader = $this->getService('activity_loader'); + $selection = $this->selector->getSelection($input, new SelectorConfig(envRequired: !($input->getOption('all') || $input->getArgument('id')))); - $executable = $this->config()->get('application.executable'); + $executable = $this->config->getStr('application.executable'); - if ($this->hasSelectedEnvironment() && !$input->getOption('all')) { - $apiResource = $this->getSelectedEnvironment(); + if ($selection->hasEnvironment() && !$input->getOption('all')) { + $apiResource = $selection->getEnvironment(); } else { - $apiResource = $this->getSelectedProject(); + $apiResource = $selection->getProject(); } $id = $input->getArgument('id'); if ($id) { - $activity = $this->getSelectedProject() + $activity = $selection->getProject() ->getActivity($id); if (!$activity) { - $activity = $this->api()->matchPartialId($id, $loader->loadFromInput($apiResource, $input, 10, [Activity::STATE_PENDING, Activity::STATE_IN_PROGRESS], 'cancel') ?: [], 'Activity'); - if (!$activity) { - $this->stdErr->writeln("Activity not found: $id"); - - return 1; - } + /** @var Activity $activity */ + $activity = $this->api->matchPartialId($id, $this->activityLoader->loadFromInput($apiResource, $input, 10, [Activity::STATE_PENDING, Activity::STATE_IN_PROGRESS], 'cancel') ?: [], 'Activity'); } } else { - $activities = $loader->loadFromInput($apiResource, $input, 10, [Activity::STATE_PENDING, Activity::STATE_IN_PROGRESS], 'cancel'); + $activities = $this->activityLoader->loadFromInput($apiResource, $input, 10, [Activity::STATE_PENDING, Activity::STATE_IN_PROGRESS], 'cancel'); if (\count($activities) === 0) { $this->stdErr->writeln('No cancellable activities found'); @@ -77,22 +90,18 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } $choices = []; - /** @var QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - /** @var PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); $byId = []; - $this->api()->sortResources($activities, 'created_at'); + $this->api->sortResources($activities, 'created_at'); foreach ($activities as $activity) { $byId[$activity->id] = $activity; $choices[$activity->id] = \sprintf( '%s: %s (%s)', - $formatter->formatDate($activity->created_at), + $this->propertyFormatter->formatDate($activity->created_at), ActivityMonitor::getFormattedDescription($activity), - ActivityMonitor::formatState($activity->state) + ActivityMonitor::formatState($activity->state), ); } - $id = $questionHelper->choose($choices, 'Enter a number to choose an activity to cancel:', key($choices), true); + $id = $this->questionHelper->choose($choices, 'Enter a number to choose an activity to cancel:', (string) key($choices)); $activity = $byId[$id]; } @@ -101,7 +110,7 @@ protected function execute(InputInterface $input, OutputInterface $output) try { $activity->cancel(); } catch (BadResponseException $e) { - if ($e->getResponse() && $e->getResponse()->getStatusCode() === 400 && \strpos($e->getMessage(), 'cannot be cancelled')) { + if ($e->getResponse()->getStatusCode() === 400 && \strpos($e->getMessage(), 'cannot be cancelled')) { if (\strpos($e->getMessage(), 'cannot be cancelled in its current state')) { $activity->refresh(); $this->stdErr->writeln(\sprintf('The activity cannot be cancelled in its current state (%s).', $activity->state)); diff --git a/src/Command/Activity/ActivityCommandBase.php b/src/Command/Activity/ActivityCommandBase.php index 65317b9bab..c25f215c5a 100644 --- a/src/Command/Activity/ActivityCommandBase.php +++ b/src/Command/Activity/ActivityCommandBase.php @@ -1,30 +1,13 @@ setName('activity:get') - ->setDescription('View detailed information on a single activity') ->addArgument('id', InputArgument::OPTIONAL, 'The activity ID. Defaults to the most recent activity.') ->addOption('property', 'P', InputOption::VALUE_REQUIRED, 'The property to view') - ->addOption('type', 't', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + ->addOption( + 'type', + 't', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter by type (when selecting a default activity).' . "\n" . ArrayArgument::SPLIT_HELP - . "\nThe % or * characters can be used as a wildcard for the type, e.g. '%var%' to select variable-related activities." + . "\nThe % or * characters can be used as a wildcard for the type, e.g. '%var%' to select variable-related activities.", + null, + ActivityLoader::getAvailableTypes(), ) - ->addOption('exclude-type', 'x', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + ->addOption( + 'exclude-type', + 'x', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Exclude by type (when selecting a default activity).' . "\n" . ArrayArgument::SPLIT_HELP - . "\nThe % or * characters can be used as a wildcard to exclude types." + . "\nThe % or * characters can be used as a wildcard to exclude types.", + null, + ActivityLoader::getAvailableTypes(), ) - ->addOption('state', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter by state (when selecting a default activity): in_progress, pending, complete, or cancelled.' . "\n" . ArrayArgument::SPLIT_HELP) - ->addOption('result', null, InputOption::VALUE_REQUIRED, 'Filter by result (when selecting a default activity): success or failure') - ->addOption('incomplete', 'i', InputOption::VALUE_NONE, + ->addOption('state', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter by state (when selecting a default activity): in_progress, pending, complete, or cancelled.' . "\n" . ArrayArgument::SPLIT_HELP, null, self::STATE_VALUES) + ->addOption('result', null, InputOption::VALUE_REQUIRED, 'Filter by result (when selecting a default activity): success or failure', null, self::RESULT_VALUES) + ->addOption( + 'incomplete', + 'i', + InputOption::VALUE_NONE, 'Include only incomplete activities (when selecting a default activity).' - . "\n" . 'This is a shorthand for --state=in_progress,pending') + . "\n" . 'This is a shorthand for --state=in_progress,pending', + ) ->addOption('all', 'a', InputOption::VALUE_NONE, 'Check recent activities on all environments (when selecting a default activity)'); - $this->addProjectOption() - ->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); Table::configureInput($this->getDefinition()); PropertyFormatter::configureInput($this->getDefinition()); $this->addExample('Find the time a project was created', '--all --type project.create -P completed_at'); $this->addExample('Find the duration (in seconds) of the last activity', '-P duration'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input, $input->getOption('all') || $input->getArgument('id')); - - /** @var ActivityLoader $loader */ - $loader = $this->getService('activity_loader'); + $selection = $this->selector->getSelection($input, new SelectorConfig(envRequired: !($input->getOption('all') || $input->getArgument('id')))); - if ($this->hasSelectedEnvironment() && !$input->getOption('all')) { - $apiResource = $this->getSelectedEnvironment(); + if ($selection->hasEnvironment() && !$input->getOption('all')) { + $apiResource = $selection->getEnvironment(); } else { - $apiResource = $this->getSelectedProject(); + $apiResource = $selection->getProject(); } $id = $input->getArgument('id'); if ($id) { - $activity = $this->getSelectedProject() + $activity = $selection->getProject() ->getActivity($id); if (!$activity) { - $activity = $this->api()->matchPartialId($id, $loader->loadFromInput($apiResource, $input, 10) ?: [], 'Activity'); - if (!$activity) { - $this->stdErr->writeln("Activity not found: $id"); - - return 1; - } + $activity = $this->api->matchPartialId($id, $this->activityLoader->loadFromInput($apiResource, $input, 10) ?: [], 'Activity'); } } else { - $activities = $loader->loadFromInput($apiResource, $input, 1); - /** @var Activity $activity */ + $activities = $this->activityLoader->loadFromInput($apiResource, $input, 1); $activity = reset($activities); if (!$activity) { $this->stdErr->writeln('No activities found'); @@ -84,15 +99,11 @@ protected function execute(InputInterface $input, OutputInterface $output) } } - /** @var Table $table */ - $table = $this->getService('table'); - /** @var PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - + /** @var Activity $activity */ $properties = $activity->getProperties(); - if (!$input->getOption('property') && !$table->formatIsMachineReadable()) { - $properties['description'] = ActivityMonitor::getFormattedDescription($activity, true); + if (!$input->getOption('property') && !$this->table->formatIsMachineReadable()) { + $properties['description'] = ActivityMonitor::getFormattedDescription($activity); } else { $properties['description'] = $activity->description; } @@ -103,7 +114,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } if ($property = $input->getOption('property')) { - $formatter->displayData($output, $properties, $property); + $this->propertyFormatter->displayData($output, $properties, $property); return 0; } @@ -113,29 +124,29 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln( 'The payload property has been omitted for brevity.' . ' You can still view it with the -P (--property) option.', - OutputInterface::VERBOSITY_VERBOSE + OutputInterface::VERBOSITY_VERBOSE, ); $header = []; $rows = []; foreach ($properties as $property => $value) { $header[] = $property; - $rows[] = $formatter->format($value, $property); + $rows[] = $this->propertyFormatter->format($value, $property); } - $table->renderSimple($rows, $header); + $this->table->renderSimple($rows, $header); - if (!$table->formatIsMachineReadable()) { - $executable = $this->config()->get('application.executable'); + if (!$this->table->formatIsMachineReadable()) { + $executable = $this->config->getStr('application.executable'); $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf( 'To view the log for this activity, run: %s activity:log %s', $executable, - $activity->id + $activity->id, )); $this->stdErr->writeln(sprintf( 'To list activities, run: %s activities', - $executable + $executable, )); } diff --git a/src/Command/Activity/ActivityListCommand.php b/src/Command/Activity/ActivityListCommand.php index 7c29f6276d..077f105069 100644 --- a/src/Command/Activity/ActivityListCommand.php +++ b/src/Command/Activity/ActivityListCommand.php @@ -1,18 +1,30 @@ */ + private array $tableHeader = [ 'id' => 'ID', 'created' => 'Created', 'completed' => 'Completed', @@ -27,46 +39,56 @@ class ActivityListCommand extends ActivityCommandBase 'time_build' => 'Build time (s)', 'time_deploy' => 'Deploy time (s)', ]; - private $defaultColumns = ['id', 'created', 'description', 'progress', 'state', 'result']; - /** - * {@inheritdoc} - */ - protected function configure() + /** @var string[] */ + private array $defaultColumns = ['id', 'created', 'description', 'progress', 'state', 'result']; + + public function __construct(private readonly ActivityLoader $activityLoader, private readonly Api $api, private readonly Config $config, private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table) { - $this - ->setName('activity:list') - ->setAliases(['activities', 'act']); + parent::__construct(); + } + protected function configure(): void + { // Add the --type option, with a link to help if configured. $typeDescription = 'Filter activities by type'; - if ($this->config()->has('service.activity_type_list_url')) { - $typeDescription .= "\nFor a list of types see: " . $this->config()->get('service.activity_type_list_url') . ''; + if ($this->config->has('service.activity_type_list_url')) { + $typeDescription .= "\nFor a list of types see: " . $this->config->getStr('service.activity_type_list_url') . ''; } - $this->addOption('type', 't', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + $this->addOption( + 'type', + 't', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, $typeDescription . "\n" . ArrayArgument::SPLIT_HELP . "\nThe first part of the activity name can be omitted, e.g. 'cron' can select 'environment.cron' activities." - . "\nThe % or * characters can be used as a wildcard, e.g. '%var%' to select variable-related activities." + . "\nThe % or * characters can be used as a wildcard, e.g. '%var%' to select variable-related activities.", + null, + ActivityLoader::getAvailableTypes(), ); - $this->addOption('exclude-type', 'x', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + $this->addOption( + 'exclude-type', + 'x', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Exclude activities by type.' . "\n" . ArrayArgument::SPLIT_HELP . "\nThe first part of the activity name can be omitted, e.g. 'cron' can exclude 'environment.cron' activities." - . "\nThe % or * characters can be used as a wildcard to exclude types." + . "\nThe % or * characters can be used as a wildcard to exclude types.", + null, + ActivityLoader::getAvailableTypes(), ); $this->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Limit the number of results displayed', 10) ->addOption('start', null, InputOption::VALUE_REQUIRED, 'Only activities created before this date will be listed') - ->addOption('state', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter activities by state: in_progress, pending, complete, or cancelled.' . "\n" . ArrayArgument::SPLIT_HELP) - ->addOption('result', null, InputOption::VALUE_REQUIRED, 'Filter activities by result: success or failure') + ->addOption('state', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter activities by state: in_progress, pending, complete, or cancelled.' . "\n" . ArrayArgument::SPLIT_HELP, null, self::STATE_VALUES) + ->addOption('result', null, InputOption::VALUE_REQUIRED, 'Filter activities by result: success or failure', null, self::RESULT_VALUES) ->addOption('incomplete', 'i', InputOption::VALUE_NONE, 'Only list incomplete activities') - ->addOption('all', 'a', InputOption::VALUE_NONE, 'List activities on all environments') - ->setDescription('Get a list of activities for an environment or project'); + ->addOption('all', 'a', InputOption::VALUE_NONE, 'List activities on all environments'); Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); PropertyFormatter::configureInput($this->getDefinition()); - $this->addProjectOption() - ->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); $this->addExample('List recent activities for the current environment') ->addExample('List all recent activities for the current project', '--all') ->addExample('List recent pushes', '--type push') @@ -75,34 +97,26 @@ protected function configure() ->addExample('List up to 25 incomplete activities', '--limit 25 -i'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input, $input->getOption('all')); + $selection = $this->selector->getSelection($input, new SelectorConfig(envRequired: !$input->getOption('all'))); - $project = $this->getSelectedProject(); + $project = $selection->getProject(); - if ($this->hasSelectedEnvironment() && !$input->getOption('all')) { + if ($selection->hasEnvironment() && !$input->getOption('all')) { $environmentSpecific = true; - $apiResource = $this->getSelectedEnvironment(); + $apiResource = $selection->getEnvironment(); } else { $environmentSpecific = false; $apiResource = $project; } - - /** @var \Platformsh\Cli\Service\ActivityLoader $loader */ - $loader = $this->getService('activity_loader'); - $activities = $loader->loadFromInput($apiResource, $input); + $activities = $this->activityLoader->loadFromInput($apiResource, $input); if ($activities === []) { $this->stdErr->writeln('No activities found'); return 1; } - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - $defaultColumns = $this->defaultColumns; if (!$environmentSpecific) { @@ -115,13 +129,13 @@ protected function execute(InputInterface $input, OutputInterface $output) foreach ($activities as $activity) { $row = [ 'id' => new AdaptiveTableCell($activity->id, ['wrap' => false]), - 'created' => $formatter->format($activity['created_at'], 'created_at'), - 'completed' => $formatter->format($activity['completed_at'], 'completed_at'), - 'description' => ActivityMonitor::getFormattedDescription($activity, !$table->formatIsMachineReadable()), + 'created' => $this->propertyFormatter->format($activity['created_at'], 'created_at'), + 'completed' => $this->propertyFormatter->format($activity['completed_at'], 'completed_at'), + 'description' => ActivityMonitor::getFormattedDescription($activity, !$this->table->formatIsMachineReadable()), 'type' => new AdaptiveTableCell($activity->type, ['wrap' => false]), 'progress' => $activity->getCompletionPercent() . '%', 'state' => ActivityMonitor::formatState($activity->state), - 'result' => ActivityMonitor::formatResult($activity->result, !$table->formatIsMachineReadable()), + 'result' => ActivityMonitor::formatResult($activity->result, !$this->table->formatIsMachineReadable()), 'environments' => implode(', ', $activity->environments), ]; $timings = $activity->getProperty('timings', false, false) ?: []; @@ -131,25 +145,25 @@ protected function execute(InputInterface $input, OutputInterface $output) $rows[] = $row; } - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { if ($environmentSpecific) { $this->stdErr->writeln(sprintf( 'Activities on the project %s, environment %s:', - $this->api()->getProjectLabel($project), - $this->api()->getEnvironmentLabel($apiResource) + $this->api->getProjectLabel($project), + $this->api->getEnvironmentLabel($apiResource), )); } else { $this->stdErr->writeln(sprintf( 'Activities on the project %s:', - $this->api()->getProjectLabel($project) + $this->api->getProjectLabel($project), )); } } - $table->render($rows, $this->tableHeader, $defaultColumns); + $this->table->render($rows, $this->tableHeader, $defaultColumns); - if (!$table->formatIsMachineReadable()) { - $executable = $this->config()->get('application.executable'); + if (!$this->table->formatIsMachineReadable()) { + $executable = $this->config->getStr('application.executable'); $max = $input->getOption('limit') ? (int) $input->getOption('limit') : 10; $maybeMoreAvailable = count($activities) === $max; @@ -158,7 +172,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln('More activities may be available.'); $this->stdErr->writeln(sprintf( 'To display older activities, increase --limit above %d, or set --start to a date in the past.', - $max + $max, )); $this->suggestExclusions($activities); } @@ -166,11 +180,11 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf( 'To view the log for an activity, run: %s activity:log [id]', - $executable + $executable, )); $this->stdErr->writeln(sprintf( 'To view more information about an activity, run: %s activity:get [id]', - $executable + $executable, )); $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf('For more information, run: %s activity:list -h', $executable)); @@ -179,7 +193,10 @@ protected function execute(InputInterface $input, OutputInterface $output) return 0; } - private function suggestExclusions(array $activities) + /** + * @param Activity[] $activities + */ + private function suggestExclusions(array $activities): void { $counts = []; foreach ($activities as $activity) { diff --git a/src/Command/Activity/ActivityLogCommand.php b/src/Command/Activity/ActivityLogCommand.php index 1880f1325f..31b3206d3e 100644 --- a/src/Command/Activity/ActivityLogCommand.php +++ b/src/Command/Activity/ActivityLogCommand.php @@ -1,86 +1,103 @@ setName('activity:log') - ->setDescription('Display the log for an activity') ->addArgument('id', InputArgument::OPTIONAL, 'The activity ID. Defaults to the most recent activity.') ->addOption( 'refresh', null, InputOption::VALUE_REQUIRED, 'Activity refresh interval (seconds). Set to 0 to disable refreshing.', - 3 + 3, ) ->addOption('timestamps', 't', InputOption::VALUE_NONE, 'Display a timestamp next to each message') - ->addOption('type', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + ->addOption( + 'type', + null, + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter by type (when selecting a default activity).' . "\n" . ArrayArgument::SPLIT_HELP - . "\nThe % or * characters can be used as a wildcard for the type, e.g. '%var%' to select variable-related activities." + . "\nThe % or * characters can be used as a wildcard for the type, e.g. '%var%' to select variable-related activities.", + null, + ActivityLoader::getAvailableTypes(), ) - ->addOption('exclude-type', 'x', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + ->addOption( + 'exclude-type', + 'x', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Exclude by type (when selecting a default activity).' . "\n" . ArrayArgument::SPLIT_HELP - . "\nThe % or * characters can be used as a wildcard to exclude types." + . "\nThe % or * characters can be used as a wildcard to exclude types.", + null, + ActivityLoader::getAvailableTypes(), ) - ->addOption('state', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter by state (when selecting a default activity): in_progress, pending, complete, or cancelled.' . "\n" . ArrayArgument::SPLIT_HELP) - ->addOption('result', null, InputOption::VALUE_REQUIRED, 'Filter by result (when selecting a default activity): success or failure') - ->addOption('incomplete', 'i', InputOption::VALUE_NONE, + ->addOption('state', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter by state (when selecting a default activity): in_progress, pending, complete, or cancelled.' . "\n" . ArrayArgument::SPLIT_HELP, null, self::STATE_VALUES) + ->addOption('result', null, InputOption::VALUE_REQUIRED, 'Filter by result (when selecting a default activity): success or failure', null, self::RESULT_VALUES) + ->addOption( + 'incomplete', + 'i', + InputOption::VALUE_NONE, 'Include only incomplete activities (when selecting a default activity).' - . "\n" . 'This is a shorthand for --state=in_progress,pending') + . "\n" . 'This is a shorthand for --state=in_progress,pending', + ) ->addOption('all', 'a', InputOption::VALUE_NONE, 'Check recent activities on all environments (when selecting a default activity)'); PropertyFormatter::configureInput($this->getDefinition()); - $this->addProjectOption() - ->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); $this->addExample('Display the log for the last push on the current environment', '--type environment.push') ->addExample('Display the log for the last activity on the current project', '--all') ->addExample('Display the log for the last push, with microsecond timestamps', "-a -t --type %push --date-fmt 'Y-m-d\TH:i:s.uP'"); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input, $input->getOption('all') || $input->getArgument('id')); + $selection = $this->selector->getSelection($input, new SelectorConfig(envRequired: !($input->getOption('all') || $input->getArgument('id')))); - /** @var \Platformsh\Cli\Service\ActivityLoader $loader */ - $loader = $this->getService('activity_loader'); - - if ($this->hasSelectedEnvironment() && !$input->getOption('all')) { - $apiResource = $this->getSelectedEnvironment(); + if ($selection->hasEnvironment() && !$input->getOption('all')) { + $apiResource = $selection->getEnvironment(); } else { - $apiResource = $this->getSelectedProject(); + $apiResource = $selection->getProject(); } $id = $input->getArgument('id'); if ($id) { - $activity = $this->getSelectedProject() + $activity = $selection->getProject() ->getActivity($id); if (!$activity) { - $activity = $this->api()->matchPartialId($id, $loader->loadFromInput($apiResource, $input, 10) ?: [], 'Activity'); - if (!$activity) { - $this->stdErr->writeln("Activity not found: $id"); - - return 1; - } + /** @var Activity $activity */ + $activity = $this->api->matchPartialId($id, $this->activityLoader->loadFromInput($apiResource, $input, 10) ?: [], 'Activity'); } } else { - $activities = $loader->loadFromInput($apiResource, $input, 1); - /** @var Activity $activity */ + $activities = $this->activityLoader->loadFromInput($apiResource, $input, 1); $activity = reset($activities); if (!$activity) { $this->stdErr->writeln('No activities found'); @@ -89,14 +106,11 @@ protected function execute(InputInterface $input, OutputInterface $output) } } - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - $this->stdErr->writeln([ sprintf('Activity ID: %s', $activity->id), sprintf('Type: %s', $activity->type), sprintf('Description: %s', ActivityMonitor::getFormattedDescription($activity)), - sprintf('Created: %s', $formatter->format($activity->created_at, 'created_at')), + sprintf('Created: %s', $this->propertyFormatter->format($activity->created_at, 'created_at')), sprintf('State: %s', ActivityMonitor::formatState($activity->state)), 'Log: ', ]); @@ -106,21 +120,18 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($timestamps && $input->hasOption('date-fmt') && $input->getOption('date-fmt') !== null) { $timestamps = $input->getOption('date-fmt'); } elseif ($timestamps) { - $timestamps = $this->config()->getWithDefault('application.date_format', 'c'); + $timestamps = $this->config->getStr('application.date_format'); } - - /** @var ActivityMonitor $monitor */ - $monitor = $this->getService('activity_monitor'); if ($refresh > 0 && !$this->runningViaMulti && !$activity->isComplete() && $activity->state !== Activity::STATE_CANCELLED) { - $monitor->waitAndLog($activity, $refresh, $timestamps, false, $output); + $this->activityMonitor->waitAndLog($activity, $refresh, $timestamps, false, $output); // Once the activity is complete, something has probably changed in // the project's environments, so this is a good opportunity to // clear the cache. - $this->api()->clearEnvironmentsCache($activity->project); + $this->api->clearEnvironmentsCache($activity->project); } else { $items = $activity->readLog(); - $output->write($monitor->formatLog($items, $timestamps)); + $output->write($this->activityMonitor->formatLog($items, $timestamps)); } return 0; diff --git a/src/Command/ApiCurlCommand.php b/src/Command/ApiCurlCommand.php index d8504f190f..3fd7ac6f57 100644 --- a/src/Command/ApiCurlCommand.php +++ b/src/Command/ApiCurlCommand.php @@ -1,33 +1,32 @@ setName('api:curl') - ->setDescription(sprintf('Run an authenticated cURL request on the %s API', $this->config()->get('service.name'))); + parent::__construct(); + } + protected function configure(): void + { CurlCli::configureInput($this->getDefinition()); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $url = $this->config()->getApiUrl(); - - // Initialize the API service so that it gets CommandBase's event listeners - // (allowing for auto login). - $this->api(); - - /** @var CurlCli $curl */ - $curl = $this->getService('curl_cli'); - - return $curl->run($url, $input, $output); + return $this->curlCli->run($this->config->getApiUrl(), $input, $output); } } diff --git a/src/Command/App/AppConfigGetCommand.php b/src/Command/App/AppConfigGetCommand.php index dca2e162f6..617daba115 100644 --- a/src/Command/App/AppConfigGetCommand.php +++ b/src/Command/App/AppConfigGetCommand.php @@ -1,56 +1,62 @@ setName('app:config-get') - ->setDescription('View the configuration of an app') ->addOption('property', 'P', InputOption::VALUE_REQUIRED, 'The configuration property to view') ->addOption('refresh', null, InputOption::VALUE_NONE, 'Whether to refresh the cache'); - $this->addProjectOption(); - $this->addEnvironmentOption(); - $this->addAppOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->selector->addAppOption($this->getDefinition()); + $this->addCompleter($this->selector); $this->addOption('identity-file', 'i', InputOption::VALUE_REQUIRED, '[Deprecated option, no longer used]'); } /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { // Allow override via PLATFORM_APPLICATION. - $prefix = $this->config()->get('service.env_prefix'); + $prefix = $this->config->getStr('service.env_prefix'); if (getenv($prefix . 'APPLICATION') && !LocalHost::conflictsWithCommandLineOptions($input, $prefix)) { - $this->debug('Reading application config from environment variable ' . $prefix . 'APPLICATION'); - $decoded = json_decode(base64_decode(getenv($prefix . 'APPLICATION'), true), true); + $this->io->debug('Reading application config from environment variable ' . $prefix . 'APPLICATION'); + $decoded = json_decode((string) base64_decode(getenv($prefix . 'APPLICATION'), true), true); if (!is_array($decoded)) { throw new \RuntimeException('Failed to decode: ' . $prefix . 'APPLICATION'); } $appConfig = new AppConfig($decoded); } else { - $this->chooseEnvFilter = $this->filterEnvsMaybeActive(); - $this->validateInput($input); - $this->warnAboutDeprecatedOptions(['identity-file']); - - $appConfig = $this->selectRemoteContainer($input, false) - ->getConfig(); + $selection = $this->selector->getSelection($input, new SelectorConfig(chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive())); + $this->io->warnAboutDeprecatedOptions(['identity-file']); + $appConfig = $selection->getRemoteContainer()->getConfig(); } - - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - $formatter->displayData($output, $appConfig->getNormalized(), $input->getOption('property')); + $this->propertyFormatter->displayData($output, $appConfig->getNormalized(), $input->getOption('property')); + return 0; } } diff --git a/src/Command/App/AppListCommand.php b/src/Command/App/AppListCommand.php index 05010900a7..897d8e3fa7 100644 --- a/src/Command/App/AppListCommand.php +++ b/src/Command/App/AppListCommand.php @@ -1,44 +1,57 @@ 'Disk', 'Size', 'path' => 'Path']; - private $defaultColumns = ['name', 'type']; + /** @var array */ + private array $tableHeader = ['Name', 'Type', 'disk' => 'Disk', 'Size', 'path' => 'Path']; + /** @var string[] */ + private array $defaultColumns = ['name', 'type']; - /** - * {@inheritdoc} - */ - protected function configure() + public function __construct(private readonly Api $api, private readonly ApplicationFinder $applicationFinder, private readonly Config $config, private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table) { - $this->setName('app:list') - ->setAliases(['apps']) - ->setDescription('List apps in the project') + parent::__construct(); + } + + protected function configure(): void + { + $this ->addOption('refresh', null, InputOption::VALUE_NONE, 'Whether to refresh the cache') ->addOption('pipe', null, InputOption::VALUE_NONE, 'Output a list of app names only'); - $this->addProjectOption() - ->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); } /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->chooseEnvFilter = $this->filterEnvsMaybeActive(); - $this->validateInput($input); + $selection = $this->selector->getSelection($input, new SelectorConfig(chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive())); // Find a list of deployed web apps. - $deployment = $this->api() - ->getCurrentDeployment($this->getSelectedEnvironment(), $input->getOption('refresh')); + $deployment = $this->api + ->getCurrentDeployment($selection->getEnvironment(), $input->getOption('refresh')); $apps = $deployment->webapps; if (!count($apps)) { @@ -62,9 +75,8 @@ protected function execute(InputInterface $input, OutputInterface $output) // @todo The "Local path" column is mainly here for legacy reasons, and can be removed in a future version. $showLocalPath = false; $localApps = []; - if (($projectRoot = $this->getProjectRoot()) && $this->selectedProjectIsCurrent() && $this->config()->has('service.app_config_file')) { - /** @var \Platformsh\Cli\Local\ApplicationFinder $finder */ - $finder = $this->getService('app_finder'); + if (($projectRoot = $this->selector->getProjectRoot()) && $this->selector->isProjectCurrent($selection->getProject()) && $this->config->has('service.app_config_file')) { + $finder = $this->applicationFinder; $localApps = $finder->findApplications($projectRoot); $showLocalPath = true; } @@ -86,58 +98,52 @@ protected function execute(InputInterface $input, OutputInterface $output) $defaultColumns[] = 'path'; } - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - $rows = []; foreach ($apps as $app) { - $row = [$app->name, $formatter->format($app->type, 'service_type'), 'disk' => $app->disk, $app->size]; + $row = [$app->name, $this->propertyFormatter->format($app->type, 'service_type'), 'disk' => (string) $app->disk, $app->size]; if ($showLocalPath) { $row['path'] = $getLocalPath($app->name); } $rows[] = $row; } - - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->stdErr->writeln(sprintf( 'Applications on the project %s, environment %s:', - $this->api()->getProjectLabel($this->getSelectedProject()), - $this->api()->getEnvironmentLabel($this->getSelectedEnvironment()) + $this->api->getProjectLabel($selection->getProject()), + $this->api->getEnvironmentLabel($selection->getEnvironment()), )); } - $table->render($rows, $headers, $defaultColumns); + $this->table->render($rows, $headers, $defaultColumns); - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->recommendOtherCommands($deployment); } return 0; } - private function recommendOtherCommands(EnvironmentDeployment $deployment) + private function recommendOtherCommands(EnvironmentDeployment $deployment): void { $lines = []; - $executable = $this->config()->get('application.executable'); + $executable = $this->config->getStr('application.executable'); if ($deployment->services) { $lines[] = sprintf( 'To list services, run: %s services', - $executable + $executable, ); } if ($deployment->workers) { $lines[] = sprintf( 'To list workers, run: %s workers', - $executable + $executable, ); } if ($info = $deployment->getProperty('project_info', false)) { - if (!empty($info['settings']['sizing_api_enabled']) && $this->config()->get('api.sizing') && $this->config()->isCommandEnabled('resources:set')) { + if (!empty($info['settings']['sizing_api_enabled']) && $this->config->getBool('api.sizing') && $this->config->isCommandEnabled('resources:set')) { $lines[] = sprintf( "To configure resources, run: %s resources:set", - $executable + $executable, ); } } diff --git a/src/Command/Auth/ApiTokenLoginCommand.php b/src/Command/Auth/ApiTokenLoginCommand.php index 4f44ba0e7d..f862b64aff 100644 --- a/src/Command/Auth/ApiTokenLoginCommand.php +++ b/src/Command/Auth/ApiTokenLoginCommand.php @@ -1,70 +1,71 @@ config()->get('service.name'); - $executable = $this->config()->get('application.executable'); - - $this->setName('auth:api-token-login'); - if ($this->config()->getWithDefault('application.login_method', 'browser') === 'api-token') { - $this->setAliases(['login']); - } - - $this->setDescription('Log in to ' . $service . ' using an API token'); + parent::__construct(); + } + protected function configure(): void + { + $service = $this->config->getStr('service.name'); + $executable = $this->config->getStr('application.executable'); $help = 'Use this command to log in to your ' . $service . ' account using an API token.'; - if ($this->config()->has('service.register_url')) { - $help .= "\n\nYou can create an account at:\n " . $this->config()->get('service.register_url') . ''; + if ($this->config->has('service.register_url')) { + $help .= "\n\nYou can create an account at:\n " . $this->config->getStr('service.register_url') . ''; } - if ($this->config()->has('service.api_tokens_url')) { + if ($this->config->has('service.api_tokens_url')) { $help .= "\n\nIf you have an account, but you do not already have an API token, you can create one here:\n " - . $this->config()->get('service.api_tokens_url') . ''; + . $this->config->getStr('service.api_tokens_url') . ''; } $help .= "\n\nAlternatively, to log in to the CLI with a browser, run:\n " . $executable . ' auth:browser-login'; $this->setHelp($help); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - if ($this->api()->hasApiToken(false)) { + if ($this->api->hasApiToken(false)) { $this->stdErr->writeln('An API token is already set via config'); return 1; } if (!$input->isInteractive()) { $this->stdErr->writeln('Non-interactive use of this command is not supported.'); - $this->stdErr->writeln("\n" . $this->getNonInteractiveAuthHelp('comment')); + $this->stdErr->writeln("\n" . $this->login->getNonInteractiveAuthHelp('comment')); return 1; } - $tokenClient = $this->api()->getExternalHttpClient(); - $clientId = $this->config()->get('api.oauth2_client_id'); - $tokenUrl = $this->config()->get('api.oauth2_token_url'); - - $validator = function ($apiToken) use ($tokenClient, $clientId, $tokenUrl) { + $validator = function (string $apiToken): string { $apiToken = trim($apiToken); if (!strlen($apiToken)) { throw new \RuntimeException('The token cannot be empty'); } try { - $token = (new ApiToken($tokenClient, [ - 'client_id' => $clientId, - 'token_url' => $tokenUrl, - 'auth_location' => 'headers', + $provider = $this->api->getClient()->getConnector()->getOAuth2Provider(); + $token = $provider->getAccessToken(new ApiToken(), [ 'api_token' => $apiToken, - ]))->getToken(); + ]); } catch (BadResponseException $e) { if ($this->exceptionMeansInvalidToken($e)) { throw new \RuntimeException('Invalid API token'); @@ -79,16 +80,13 @@ protected function execute(InputInterface $input, OutputInterface $output) return $apiToken; }; - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); $question = new Question("Please enter an API token:\n> "); $question->setValidator($validator); $question->setMaxAttempts(5); $question->setHidden(true); - $questionHelper->ask($input, $output, $question); + $this->questionHelper->ask($input, $output, $question); - $this->finalizeLogin(); + $this->login->finalize(); return 0; } @@ -99,14 +97,12 @@ protected function execute(InputInterface $input, OutputInterface $output) * @param string $apiToken * @param AccessToken $accessToken */ - private function saveTokens($apiToken, AccessToken $accessToken) { - $this->api()->logout(); - - /** @var \Platformsh\Cli\Service\TokenConfig $tokenConfig */ - $tokenConfig = $this->getService('token_config'); - $tokenConfig->storage()->storeToken($apiToken); + private function saveTokens(string $apiToken, AccessToken $accessToken): void + { + $this->api->logout(); + $this->tokenConfig->storage()->storeToken($apiToken); - $this->api() + $this->api ->getClient(false, true) ->getConnector() ->saveToken($accessToken); @@ -117,21 +113,22 @@ private function saveTokens($apiToken, AccessToken $accessToken) { * * @return bool */ - private function exceptionMeansInvalidToken(\Exception $e) { - if (!$e instanceof BadResponseException || !$e->getResponse() || !in_array($e->getResponse()->getStatusCode(), [400, 401], true)) { + private function exceptionMeansInvalidToken(\Exception $e): bool + { + if (!$e instanceof BadResponseException || !in_array($e->getResponse()->getStatusCode(), [400, 401], true)) { return false; } - $json = $e->getResponse()->json(); + $json = (array) Utils::jsonDecode((string) $e->getResponse()->getBody(), true); // Compatibility with legacy auth provider. if (isset($json['error'], $json['error_description']) && $json['error'] === 'invalid_grant' - && stripos($json['error_description'], 'Invalid API token') !== false) { + && stripos((string) $json['error_description'], 'Invalid API token') !== false) { return true; } // Compatibility with new auth provider. if (isset($json['error'], $json['error_hint']) && $json['error'] === 'request_unauthorized' - && stripos($json['error_hint'], 'API token') !== false) { + && stripos((string) $json['error_hint'], 'API token') !== false) { return true; } diff --git a/src/Command/Auth/AuthInfoCommand.php b/src/Command/Auth/AuthInfoCommand.php index 1ba4c21d82..6d5f66ad95 100644 --- a/src/Command/Auth/AuthInfoCommand.php +++ b/src/Command/Auth/AuthInfoCommand.php @@ -1,21 +1,31 @@ setName('auth:info') - ->setDescription('Display your account information') ->addArgument('property', InputArgument::OPTIONAL, 'The account property to view') ->addOption('no-auto-login', null, InputOption::VALUE_NONE, 'Skips auto login. Nothing will be output if not logged in, and the exit code will be 0, assuming no other errors.') ->addOption('property', 'P', InputOption::VALUE_REQUIRED, 'The account property to view (alternate syntax)') @@ -26,12 +36,9 @@ protected function configure() $this->addExample('Print your user ID (or nothing if not logged in)', 'id --no-auto-login'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - - if ($input->getOption('no-auto-login') && !$this->api()->isLoggedIn()) { + if ($input->getOption('no-auto-login') && !$this->api->isLoggedIn()) { $this->stdErr->writeln('Not logged in', OutputInterface::VERBOSITY_VERBOSE); return 0; } @@ -43,8 +50,8 @@ protected function execute(InputInterface $input, OutputInterface $output) sprintf( 'You cannot use both the <%s> argument and the --%s option', 'property', - 'property' - ) + 'property', + ), ); } $property = $input->getOption('property'); @@ -52,16 +59,12 @@ protected function execute(InputInterface $input, OutputInterface $output) // Exit early if it's the user ID. if ($property === 'id') { - $userId = $this->api()->getMyUserId($input->getOption('refresh')); - if ($userId === false) { - $this->stdErr->writeln('The current session is not associated with a user ID'); - return 1; - } + $userId = $this->api->getMyUserId($input->getOption('refresh')); $output->writeln($userId); return 0; } - $info = $this->api()->getMyAccount($input->getOption('refresh')); + $info = $this->api->getMyAccount($input->getOption('refresh')); $propertiesToDisplay = ['id', 'first_name', 'last_name', 'username', 'email', 'phone_number_verified']; $info = array_intersect_key($info, array_flip($propertiesToDisplay)); @@ -69,20 +72,20 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($property) { if (!isset($info[$property])) { // Backwards compatibility. - if ($property === 'display_name' && isset($info['first_name'], $info['last_name'])) { + if ($property === 'display_name') { $this->stdErr->writeln('Deprecated: the "display_name" property has been replaced by "first_name" and "last_name".'); $info[$property] = \sprintf('%s %s', $info['first_name'], $info['last_name']); - } elseif ($property === 'mail' && isset($info['email'])) { + } elseif ($property === 'mail') { $this->stdErr->writeln('Deprecated: the "mail" property is now named "email".'); $info[$property] = $info['email']; - } elseif ($property === 'uuid' && isset($info['id'])) { + } elseif ($property === 'uuid') { $this->stdErr->writeln('Deprecated: the "uuid" property is now named "id".'); $info[$property] = $info['id']; } else { throw new InvalidArgumentException('Property not found: ' . $property); } } - $output->writeln($formatter->format($info[$property], $property)); + $output->writeln($this->propertyFormatter->format($info[$property], $property)); return 0; } @@ -90,20 +93,16 @@ protected function execute(InputInterface $input, OutputInterface $output) $values = []; $header = []; foreach ($propertiesToDisplay as $property) { - if (isset($info[$property])) { - $values[] = $formatter->format($info[$property], $property); - $header[] = $property; - } + $values[] = $this->propertyFormatter->format($info[$property], $property); + $header[] = $property; } - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - $table->renderSimple($values, $header); + $this->table->renderSimple($values, $header); - if (!$table->formatIsMachineReadable() && ($this->config()->getSessionId() !== 'default' || count($this->api()->listSessionIds()) > 1)) { + if (!$this->table->formatIsMachineReadable() && ($this->config->getSessionId() !== 'default' || count($this->api->listSessionIds()) > 1)) { $this->stdErr->writeln(''); - $this->stdErr->writeln(sprintf('The current session ID is: %s', $this->config()->getSessionId())); - if (!$this->config()->isSessionIdFromEnv()) { - $this->stdErr->writeln(sprintf('Change this using: %s session:switch', $this->config()->get('application.executable'))); + $this->stdErr->writeln(sprintf('The current session ID is: %s', $this->config->getSessionId())); + if (!$this->config->isSessionIdFromEnv()) { + $this->stdErr->writeln(sprintf('Change this using: %s session:switch', $this->config->getStr('application.executable'))); } } diff --git a/src/Command/Auth/AuthTokenCommand.php b/src/Command/Auth/AuthTokenCommand.php index ab86fb5b48..163269adce 100644 --- a/src/Command/Auth/AuthTokenCommand.php +++ b/src/Command/Auth/AuthTokenCommand.php @@ -1,24 +1,31 @@ setName('auth:token') - ->setDescription(sprintf( - 'Obtain an OAuth 2 access token for requests to %s APIs', - $this->config()->get('service.name') - )) + $this ->addOption('header', 'H', InputOption::VALUE_NONE, 'Prefix the token with "' . self::RFC6750_PREFIX . '" to make an RFC 6750 header') ->addOption('no-warn', 'W', InputOption::VALUE_NONE, 'Suppress the warning that is printed by default to stderr.' . ' This option is preferred over redirecting stderr, as that would hide other potentially useful messages.'); @@ -26,10 +33,10 @@ protected function configure() 'This command prints a valid OAuth 2 access token to stdout. It can be used to make API requests via standard Bearer authentication (RFC 6750).' . "\n\n" . 'Warning: access tokens must be kept secret.' . "\n\n" . 'Using this command is not generally recommended, as it increases the chance of the token being leaked.' - . ' Take care not to expose the token in a shared program or system, or to send the token to the wrong API domain.' + . ' Take care not to expose the token in a shared program or system, or to send the token to the wrong API domain.', ); - $executable = $this->config()->get('application.executable'); - $apiUrl = $this->config()->getApiUrl(); + $executable = $this->config->getStr('application.executable'); + $apiUrl = $this->config->getApiUrl(); $examples = [ 'Print the payload for JWT-formatted tokens' => \sprintf('%s auth:token -W | cut -d. -f2 | base64 -d', $executable), 'Use the token in a curl command' => \sprintf('curl -H"$(%s auth:token -HW)" %s/users/me', $executable, rtrim($apiUrl, '/')), @@ -41,15 +48,15 @@ protected function configure() $this->setHelp($help); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { if (!$input->getOption('no-warn')) { $this->stdErr->writeln( - 'Warning: keep access tokens secret.' + 'Warning: keep access tokens secret.', ); } - $token = $this->api()->getAccessToken(); + $token = $this->api->getAccessToken(); $output->write($input->getOption('header') ? self::RFC6750_PREFIX . $token : $token); diff --git a/src/Command/Auth/BrowserLoginCommand.php b/src/Command/Auth/BrowserLoginCommand.php index 8f161899c6..5c667c0625 100644 --- a/src/Command/Auth/BrowserLoginCommand.php +++ b/src/Command/Auth/BrowserLoginCommand.php @@ -1,9 +1,19 @@ config()->get('service.name'); - $applicationName = $this->config()->get('application.name'); + parent::__construct(); + } - $this->setName('auth:browser-login'); - if ($this->config()->getWithDefault('application.login_method', 'browser') === 'browser') { - $this->setAliases(['login']); - } + protected function configure(): void + { + $applicationName = $this->config->getStr('application.name'); - $this->setDescription('Log in to ' . $service . ' via a browser') + $this ->addOption('force', 'f', InputOption::VALUE_NONE, 'Log in again, even if already logged in') - ->addOption('method', null, InputOption::VALUE_REQUIRED|InputOption::VALUE_IS_ARRAY, 'Require specific authentication method(s)') + ->addOption('method', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Require specific authentication method(s)') ->addOption('max-age', null, InputOption::VALUE_REQUIRED, 'The maximum age (in seconds) of the web authentication session'); Url::configureInput($this->getDefinition()); - $executable = $this->config()->get('application.executable'); + $executable = $this->config->getStr('application.executable'); $help = 'Use this command to log in to the ' . $applicationName . ' using a web browser.' . "\n\nIt launches a temporary local website which redirects you to log in if necessary, and then captures the resulting authorization code." . "\n\nYour system's default browser will be used. You can override this using the --browser option." . "\n\nAlternatively, to log in using an API token (without a browser), run: $executable auth:api-token-login" - . "\n\n" . $this->getNonInteractiveAuthHelp(); + . "\n\n" . $this->login->getNonInteractiveAuthHelp(); $this->setHelp(\wordwrap($help, 80)); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - if ($this->api()->hasApiToken(false)) { + if ($this->api->hasApiToken(false)) { $this->stdErr->writeln('Cannot log in via the browser, because an API token is set via config.'); return 1; } if (!$input->isInteractive()) { $this->stdErr->writeln('Non-interactive use of this command is not supported.'); - $this->stdErr->writeln("\n" . $this->getNonInteractiveAuthHelp('comment')); + $this->stdErr->writeln("\n" . $this->login->getNonInteractiveAuthHelp('comment')); return 1; } - if ($this->config()->getSessionId() !== 'default' || count($this->api()->listSessionIds()) > 1) { - $this->stdErr->writeln(sprintf('The current session ID is: %s', $this->config()->getSessionId())); - if (!$this->config()->isSessionIdFromEnv()) { - $this->stdErr->writeln(sprintf('Change this using: %s session:switch', $this->config()->get('application.executable'))); + if ($this->config->getSessionId() !== 'default' || count($this->api->listSessionIds()) > 1) { + $this->stdErr->writeln(sprintf('The current session ID is: %s', $this->config->getSessionId())); + if (!$this->config->isSessionIdFromEnv()) { + $this->stdErr->writeln(sprintf('Change this using: %s session:switch', $this->config->getStr('application.executable'))); } $this->stdErr->writeln(''); } - $connector = $this->api()->getClient(false)->getConnector(); + $connector = $this->api->getClient(false)->getConnector(); $force = $input->getOption('force'); if (!$force && $input->getOption('method') === [] && $input->getOption('max-age') === null && $connector->isLoggedIn()) { // Get account information, simultaneously checking whether the API // login is still valid. If the request works, then do not log in // again (unless --force is used). If the request fails, proceed // with login. - $api = $this->api(); + $api = $this->api; try { $api->inLoginCheck = true; @@ -77,25 +88,16 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(\sprintf( 'You are already logged in as %s (%s)', $account['username'], - $account['email'] + $account['email'], )); - if ($input->isInteractive()) { - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - if (!$questionHelper->confirm('Log in anyway?', false)) { - return 1; - } - $force = true; - } else { - // USE THE FORCE - $this->stdErr->writeln('Use the --force (-f) option to log in again.'); - - return 0; + if (!$this->questionHelper->confirm('Log in anyway?', false)) { + return 1; } + $force = true; } catch (BadResponseException $e) { - if ($e->getResponse() && in_array($e->getResponse()->getStatusCode(), [400, 401], true)) { - $this->debug('Already logged in, but a test request failed. Continuing with login.'); + if (in_array($e->getResponse()->getStatusCode(), [400, 401], true)) { + $this->io->debug('Already logged in, but a test request failed. Continuing with login.'); } else { throw $e; } @@ -116,7 +118,7 @@ protected function execute(InputInterface $input, OutputInterface $output) if (stripos($e->getMessage(), 'failed to find') !== false) { $this->stdErr->writeln(sprintf('Failed to find an available port between %d and %d.', $start, $end)); $this->stdErr->writeln('Check if you have unnecessary services running on these ports.'); - $this->stdErr->writeln(sprintf('For more options, run: %s help login', $this->config()->get('application.executable'))); + $this->stdErr->writeln(sprintf('For more options, run: %s help login', $this->config->getStr('application.executable'))); return 1; } @@ -127,7 +129,7 @@ protected function execute(InputInterface $input, OutputInterface $output) // Then create the document root for the local server. This needs to be // outside the CLI itself (since the CLI may be run as a Phar). - $listenerDir = $this->config()->getWritableUserDir() . '/oauth-listener'; + $listenerDir = $this->config->getWritableUserDir() . '/oauth-listener'; $this->createDocumentRoot($listenerDir); // Create the file where a response will be saved (by the local server @@ -136,7 +138,7 @@ protected function execute(InputInterface $input, OutputInterface $output) if (file_put_contents($responseFile, '', LOCK_EX) === false) { throw new \RuntimeException('Failed to create temporary file: ' . $responseFile); } - chmod($responseFile, 0600); + chmod($responseFile, 0o600); // Start the local server. $process = new Process([ @@ -145,21 +147,21 @@ protected function execute(InputInterface $input, OutputInterface $output) '-S', $localAddress, '-t', - $listenerDir + $listenerDir, ]); $codeVerifier = $this->generateCodeVerifier(); $process->setEnv([ - 'CLI_OAUTH_APP_NAME' => $this->config()->get('application.name'), + 'CLI_OAUTH_APP_NAME' => $this->config->getStr('application.name'), 'CLI_OAUTH_STATE' => $this->generateCodeVerifier(), // the state can just be any random string 'CLI_OAUTH_CODE_CHALLENGE' => $this->convertVerifierToChallenge($codeVerifier), - 'CLI_OAUTH_AUTH_URL' => $this->config()->get('api.oauth2_auth_url'), - 'CLI_OAUTH_CLIENT_ID' => $this->config()->get('api.oauth2_client_id'), + 'CLI_OAUTH_AUTH_URL' => $this->config->get('api.oauth2_auth_url'), + 'CLI_OAUTH_CLIENT_ID' => $this->config->get('api.oauth2_client_id'), 'CLI_OAUTH_PROMPT' => $force ? 'consent select_account' : 'consent', 'CLI_OAUTH_SCOPE' => 'offline_access', 'CLI_OAUTH_FILE' => $responseFile, 'CLI_OAUTH_METHODS' => implode(' ', ArrayArgument::getOption($input, 'method')), 'CLI_OAUTH_MAX_AGE' => $input->getOption('max-age'), - ] + $this->getParentEnv()); + ] + getenv()); $process->setTimeout(null); $this->stdErr->writeln('Starting local web server with command: ' . $process->getCommandLine() . '', OutputInterface::VERBOSITY_VERY_VERBOSE); $process->start(); @@ -175,11 +177,7 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - - // Open the local server URL in a browser (or print the URL). - /** @var \Platformsh\Cli\Service\Url $urlService */ - $urlService = $this->getService('url'); - if ($urlService->openUrl($localUrl, false)) { + if ($this->url->openUrl($localUrl, false)) { $this->stdErr->writeln(sprintf('Opened URL: %s', $localUrl)); $this->stdErr->writeln('Please use the browser to log in.'); } else { @@ -195,7 +193,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(''); // Wait for the file to be filled with an OAuth2 authorization code. - /** @var array|null $response */ + /** @var null|array{code: string, redirect_uri: string}|array{error: string, error_description: string, error_hint: string} $response */ $response = null; $start = time(); while ($process->isRunning()) { @@ -253,20 +251,20 @@ protected function execute(InputInterface $input, OutputInterface $output) // Using the authorization code, request an access token. $this->stdErr->writeln('Login information received. Verifying...'); - $token = $this->getAccessToken($code, $codeVerifier, isset($response['redirect_uri']) ? $response['redirect_uri'] : $localUrl); + $token = $this->getAccessToken($code, $codeVerifier, $response['redirect_uri'] ?? $localUrl); // Finalize login: log out and save the new credentials. - $this->api()->logout(); + $this->api->logout(); // Save the new tokens to the persistent session. - $session = $this->api()->getClient(false)->getConnector()->getSession(); + $session = $this->api->getClient(false)->getConnector()->getSession(); $this->saveAccessToken($token, $session); - $this->finalizeLogin(); + $this->login->finalize(); if (empty($token['refresh_token'])) { $this->stdErr->writeln(''); - $clientId = $this->config()->get('api.oauth2_client_id'); + $clientId = $this->config->getStr('api.oauth2_client_id'); $this->stdErr->writeln([ 'Warning:', 'No refresh token is available. This will cause frequent login errors.', @@ -279,97 +277,68 @@ protected function execute(InputInterface $input, OutputInterface $output) } /** - * Attempts to find parent environment variables for the local server. - * - * @return array - */ - private function getParentEnv() - { - if (PHP_VERSION_ID >= 70100) { - return getenv(); - } - if (!empty($_ENV) && stripos(ini_get('variables_order'), 'e') !== false) { - return $_ENV; - } - - return []; - } - - /** - * @param array $tokenData + * @param array $tokenData * @param SessionInterface $session */ - private function saveAccessToken(array $tokenData, SessionInterface $session) + private function saveAccessToken(array $tokenData, SessionInterface $session): void { - $token = new AccessToken($tokenData['access_token'], $tokenData['token_type'], $tokenData); - $session->setData([ - 'accessToken' => $token->getToken(), - 'tokenType' => $token->getType(), - ]); - if ($token->getExpires()) { - $session->set('expires', $token->getExpires()->getTimestamp()); - } - if ($token->getRefreshToken()) { - $session->set('refreshToken', $token->getRefreshToken()->getToken()); - } + $token = new AccessToken($tokenData); + $session->set('accessToken', $token->getToken()); + $session->set('tokenType', $tokenData['token_type'] ?: null); + $session->set('expires', $token->getExpires()); + $session->set('refreshToken', $token->getRefreshToken()); $session->save(); } /** * @param string $dir */ - private function createDocumentRoot($dir) + private function createDocumentRoot(string $dir): void { - if (!is_dir($dir) && !mkdir($dir, 0700, true)) { + if (!is_dir($dir) && !mkdir($dir, 0o700, true)) { throw new \RuntimeException('Failed to create temporary directory: ' . $dir); } - if (!file_put_contents($dir . '/index.php', file_get_contents(CLI_ROOT . '/resources/oauth-listener/index.php'))) { + if (!file_put_contents($dir . '/index.php', (string) file_get_contents(CLI_ROOT . '/resources/oauth-listener/index.php'))) { throw new \RuntimeException('Failed to write temporary file: ' . $dir . '/index.php'); } - if (!file_put_contents($dir . '/config.json', json_encode($this->config()->getWithDefault('browser_login', []), JSON_UNESCAPED_SLASHES))) { + if (!file_put_contents($dir . '/config.json', (string) json_encode((array) $this->config->get('browser_login'), JSON_UNESCAPED_SLASHES))) { throw new \RuntimeException('Failed to write temporary file: ' . $dir . '/config.json'); } } /** - * Exchange the authorization code for an access token. - * - * @param string $authCode - * @param string $codeVerifier - * @param string $redirectUri + * Exchanges the authorization code for an access token. * - * @return array + * @return array */ - private function getAccessToken($authCode, $codeVerifier, $redirectUri) + private function getAccessToken(string $authCode, string $codeVerifier, string $redirectUri): array { - $client = new Client(); - $request = $client->createRequest('post', $this->config()->get('api.oauth2_token_url'), [ - 'body' => [ - 'grant_type' => 'authorization_code', - 'code' => $authCode, - 'client_id' => $this->config()->get('api.oauth2_client_id'), - 'redirect_uri' => $redirectUri, - 'code_verifier' => $codeVerifier, - ], - 'auth' => false, - 'verify' => !$this->config()->getWithDefault('api.skip_ssl', false), - ]); + $client = new Client(['verify' => !$this->config->getBool('api.skip_ssl')]); + $request = new Request('POST', $this->config->getStr('api.oauth2_token_url'), body: http_build_query([ + 'grant_type' => 'authorization_code', + 'code' => $authCode, + 'redirect_uri' => $redirectUri, + 'code_verifier' => $codeVerifier, + ])); try { - $response = $client->send($request); + $response = $client->send($request, [ + 'headers' => [ + 'Content-Type' => 'application/x-www-form-urlencoded', + ], + 'auth' => [$this->config->get('api.oauth2_client_id'), ''], + ]); - return $response->json(); + return (array) Utils::jsonDecode((string) $response->getBody(), true); } catch (BadResponseException $e) { throw ApiResponseException::create($request, $e->getResponse(), $e); } } /** - * Get a PKCE code verifier to use with the OAuth2 code request. - * - * @return string + * Gets a PKCE code verifier to use with the OAuth2 code request. */ - private function generateCodeVerifier() + private function generateCodeVerifier(): string { // This uses paragonie/random_compat as a polyfill for PHP < 7.0. return $this->base64UrlEncode(random_bytes(32)); @@ -384,19 +353,15 @@ private function generateCodeVerifier() * * @return string */ - private function base64UrlEncode($data) + private function base64UrlEncode(string $data): string { return str_replace(['+', '/'], ['-', '_'], rtrim(base64_encode($data), '=')); } /** * Generates a PKCE code challenge using the S256 transformation on a verifier. - * - * @param string $verifier - * - * @return string */ - private function convertVerifierToChallenge($verifier) + private function convertVerifierToChallenge(string $verifier): string { return $this->base64UrlEncode(hash('sha256', $verifier, true)); } diff --git a/src/Command/Auth/LogoutCommand.php b/src/Command/Auth/LogoutCommand.php index c0e9137e9b..5dfa5cda25 100644 --- a/src/Command/Auth/LogoutCommand.php +++ b/src/Command/Auth/LogoutCommand.php @@ -1,49 +1,53 @@ setName('auth:logout') - ->setAliases(['logout']) ->addOption('all', 'a', InputOption::VALUE_NONE, 'Log out from all local sessions') - ->addOption('other', null, InputOption::VALUE_NONE, 'Log out from other local sessions') - ->setDescription('Log out of ' . $this->config()->get('service.name')); + ->addOption('other', null, InputOption::VALUE_NONE, 'Log out from other local sessions'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { // API tokens set via the environment or the config file cannot be // removed using this command. // API tokens set via the auth:api-token-login command will be safely // deleted. - if ($this->api()->hasApiToken(false)) { + if ($this->api->hasApiToken(false)) { $this->stdErr->writeln('Warning: an API token is set via config'); } if ($input->getOption('other') && !$input->getOption('all')) { - $currentSessionId = $this->config()->getSessionId(); + $currentSessionId = $this->config->getSessionId(); $this->stdErr->writeln(sprintf('The current session ID is: %s', $currentSessionId)); - $other = \array_filter($this->api()->listSessionIds(), function ($sessionId) use ($currentSessionId) { - return $sessionId !== $currentSessionId; - }); + $other = \array_filter($this->api->listSessionIds(), fn($sessionId): bool => $sessionId !== $currentSessionId); if (empty($other)) { $this->stdErr->writeln('No other sessions exist.'); return 0; } $this->stdErr->writeln(''); foreach ($other as $sessionId) { - $api = new Api($this->config()->withOverrides(['api.session_id' => $sessionId]), null, $output); + $api = new Api($this->config->withOverrides(['api.session_id' => $sessionId]), null, $output); $api->logout(); $this->stdErr->writeln(sprintf('Logged out from session: %s', $sessionId)); } @@ -52,30 +56,26 @@ protected function execute(InputInterface $input, OutputInterface $output) return 0; } - $this->api()->logout(); + $this->api->logout(); $this->stdErr->writeln('You are now logged out.'); - - // Delete session SSH configuration. - /** @var \Platformsh\Cli\Service\SshConfig $sshConfig */ - $sshConfig = $this->getService('ssh_config'); - $sshConfig->deleteSessionConfiguration(); + $this->sshConfig->deleteSessionConfiguration(); // Check for other sessions. if ($input->getOption('all')) { - $this->api()->deleteAllSessions(); + $this->api->deleteAllSessions(); $this->stdErr->writeln(''); $this->stdErr->writeln('All sessions have been deleted.'); - $this->showSessionInfo(true); + $this->api->showSessionInfo(true); return 0; } - $this->showSessionInfo(true); + $this->api->showSessionInfo(true); - if ($this->api()->anySessionsExist()) { + if ($this->api->anySessionsExist()) { $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf( 'Other sessions exist. Log out of all sessions with: %s logout --all', - $this->config()->get('application.executable') + $this->config->getStr('application.executable'), )); } diff --git a/src/Command/Auth/VerifyPhoneNumberCommand.php b/src/Command/Auth/VerifyPhoneNumberCommand.php index b2151dad89..fc9cc6d027 100644 --- a/src/Command/Auth/VerifyPhoneNumberCommand.php +++ b/src/Command/Auth/VerifyPhoneNumberCommand.php @@ -1,42 +1,46 @@ setName('auth:verify-phone-number') - ->setDescription('Verify your phone number interactively'); + parent::__construct(); } - - public function isEnabled() + public function isEnabled(): bool { - if (!$this->config()->getWithDefault('api.user_verification', false)) { + if (!$this->config->getBool('api.user_verification')) { return false; } return parent::isEnabled(); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { if (!$input->isInteractive()) { $this->stdErr->writeln('Non-interactive use of this command is not supported.'); return 1; } - $myUser = $this->api()->getUser(null, true); - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); + $myUser = $this->api->getUser(null, true); if ($myUser->phone_number_verified) { $this->stdErr->writeln('Your user account already has a verified phone number.'); @@ -46,10 +50,10 @@ protected function execute(InputInterface $input, OutputInterface $output) $defaultRegion = $myUser->country ?: null; $methods = ['sms' => 'SMS (default)', 'whatsapp' => 'WhatsApp message', 'call' => 'Call']; - $channel = $questionHelper->choose($methods, 'Enter a number to choose a phone number verification method:', 'sms'); + $channel = $this->questionHelper->choose($methods, 'Enter a number to choose a phone number verification method:', 'sms'); $phoneUtil = PhoneNumberUtil::getInstance(); - $number = $questionHelper->askInput('Please enter your phone number', null, [], function ($number) use ($phoneUtil, $defaultRegion) { + $number = $this->questionHelper->askInput('Please enter your phone number', null, [], function ($number) use ($phoneUtil, $defaultRegion) { try { $parsed = $phoneUtil->parse($number, $defaultRegion); } catch (NumberParseException $e) { @@ -63,16 +67,19 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(''); - $this->debug('E164-formatted number: ' . $number); + $this->io->debug('E164-formatted number: ' . $number); - $httpClient = $this->api()->getHttpClient(); + $httpClient = $this->api->getHttpClient(); - $sid = $httpClient->post('/users/' . rawurlencode($myUser->id) . '/phonenumber', [ + $response = $httpClient->post('/users/' . rawurlencode($myUser->id) . '/phonenumber', [ 'json' => [ 'channel' => $channel, 'phone_number' => $number, ], - ])->json()['sid']; + ]); + /** @var array{sid: string} $data */ + $data = (array) Utils::jsonDecode((string) $response->getBody(), true); + $sid = $data['sid']; if ($channel === 'call') { $this->stdErr->writeln('Calling the number ' . $number . ' with a verification code.'); @@ -84,7 +91,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(''); - $questionHelper->askInput('Please enter the verification code', null, [], function ($code) use ($httpClient, $sid , $myUser) { + $this->questionHelper->askInput('Please enter the verification code', null, [], function ($code) use ($httpClient, $sid, $myUser): void { if (!is_numeric($code)) { throw new InvalidArgumentException('Invalid verification code'); } @@ -94,15 +101,16 @@ protected function execute(InputInterface $input, OutputInterface $output) ]); } catch (BadResponseException $e) { if (($response = $e->getResponse()) && $response->getStatusCode() === 400) { - $detail = $response->json(); - throw new InvalidArgumentException(isset($detail['error']) ? ucfirst($detail['error']) : 'Invalid verification code'); + $detail = (array) Utils::jsonDecode((string) $response->getBody(), true); + throw new InvalidArgumentException(isset($detail['error']) ? ucfirst((string) $detail['error']) : 'Invalid verification code'); } throw $e; } }); - $this->debug('Refreshing phone verification status'); - $needsVerify = $httpClient->post( '/me/verification?force_refresh=1')->json(); + $this->io->debug('Refreshing phone verification status'); + $response = $httpClient->post('/me/verification?force_refresh=1'); + $needsVerify = (array) Utils::jsonDecode((string) $response->getBody(), true); $this->stdErr->writeln(''); if ($needsVerify['type'] === 'phone') { diff --git a/src/Command/Backup/BackupCreateCommand.php b/src/Command/Backup/BackupCreateCommand.php index 815b505915..65d3a536d8 100644 --- a/src/Command/Backup/BackupCreateCommand.php +++ b/src/Command/Backup/BackupCreateCommand.php @@ -1,33 +1,49 @@ setName('backup:create') - ->setAliases(['backup']) - ->setDescription('Make a backup of an environment') ->addArgument('environment', InputArgument::OPTIONAL, 'The environment') - ->addOption('live', null, InputOption::VALUE_NONE, + ->addOption( + 'live', + null, + InputOption::VALUE_NONE, 'Live backup: do not stop the environment.' . "\n" . 'If set, this leaves the environment running and open to connections during the backup.' - . "\n" . 'This reduces downtime, at the risk of backing up data in an inconsistent state.' + . "\n" . 'This reduces downtime, at the risk of backing up data in an inconsistent state.', ); - $this->addProjectOption() - ->addEnvironmentOption() - ->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); $this->addHiddenOption('unsafe', null, InputOption::VALUE_NONE, 'Deprecated option: use --live instead'); $this->setHiddenAliases(['snapshot:create', 'environment:backup']); $this->addExample('Make a backup of the current environment'); @@ -35,17 +51,16 @@ protected function configure() $this->addExample('Make a backup avoiding downtime (but risking inconsistency)', '--live'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->warnAboutDeprecatedOptions(['unsafe']); - $this->chooseEnvFilter = $this->filterEnvsMaybeActive(); - $this->validateInput($input); + $this->io->warnAboutDeprecatedOptions(['unsafe']); + $selection = $this->selector->getSelection($input, new SelectorConfig(chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive())); - $selectedEnvironment = $this->getSelectedEnvironment(); + $selectedEnvironment = $selection->getEnvironment(); $environmentId = $selectedEnvironment->id; if (!$selectedEnvironment->operationAvailable('backup', true)) { $this->stdErr->writeln( - "Operation not available: cannot create a backup of $environmentId" + "Operation not available: cannot create a backup of $environmentId", ); if ($selectedEnvironment->is_dirty) { @@ -54,11 +69,11 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln('The environment is not active.'); } else { try { - if ($this->isUserAdmin($this->getSelectedProject(), $selectedEnvironment, $this->api()->getMyUserId())) { + if ($this->isUserAdmin($selection->getProject(), $selectedEnvironment, $this->api->getMyUserId())) { $this->stdErr->writeln('You must be an administrator to create a backup.'); } } catch (\Exception $e) { - $this->debug('Error while checking access: ' . $e->getMessage()); + $this->io->debug('Error while checking access: ' . $e->getMessage()); } } @@ -70,14 +85,11 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(sprintf( 'Creating a %s of %s.', $live ? 'live backup' : 'backup', - $this->api()->getEnvironmentLabel($selectedEnvironment, 'info', false) + $this->api->getEnvironmentLabel($selectedEnvironment, 'info', false), )); $this->stdErr->writeln('Note: this may delete an older backup if the quota has been reached.'); $this->stdErr->writeln(''); - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - if (!$questionHelper->confirm('Are you sure you want to continue?')) { + if (!$this->questionHelper->confirm('Are you sure you want to continue?')) { return 1; } @@ -87,17 +99,16 @@ protected function execute(InputInterface $input, OutputInterface $output) // waitMultiple() below, allowing the backup_name to be extracted. $activities = $result->getActivities(); - if ($this->shouldWait($input)) { + if ($this->activityMonitor->shouldWait($input)) { // Strongly recommend using --no-wait in a cron job. - if (!$this->isTerminal(STDIN)) { + if (!$this->io->isTerminal(STDIN)) { $this->stdErr->writeln( - 'Warning: use the --no-wait (-W) option if you are running this in a cron job.' + 'Warning: use the --no-wait (-W) option if you are running this in a cron job.', ); } - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); - $success = $activityMonitor->waitMultiple($activities, $this->getSelectedProject()); + $activityMonitor = $this->activityMonitor; + $success = $activityMonitor->waitMultiple($activities, $selection->getProject()); if (!$success) { return 1; } @@ -113,17 +124,17 @@ protected function execute(InputInterface $input, OutputInterface $output) return 0; } - private function isUserAdmin(Project $project, Environment $environment, $userId) + private function isUserAdmin(Project $project, Environment $environment, string $userId): bool { - if ($this->config()->get('api.centralized_permissions') && $this->config()->get('api.organizations')) { - $client = $this->api()->getHttpClient(); + if ($this->config->getBool('api.centralized_permissions') && $this->config->getBool('api.organizations')) { + $client = $this->api->getHttpClient(); $endpointUrl = $project->getUri() . '/user-access'; $userAccess = ProjectUserAccess::get($userId, $endpointUrl, $client); if (!$userAccess) { return false; } $roles = $userAccess->getEnvironmentTypeRoles(); - $role = isset($roles[$environment->type]) ? $roles[$environment->type] : $userAccess->getProjectRole(); + $role = $roles[$environment->type] ?? $userAccess->getProjectRole(); return $role === 'admin'; } diff --git a/src/Command/Backup/BackupDeleteCommand.php b/src/Command/Backup/BackupDeleteCommand.php index bec2edea66..e1d7f69e35 100644 --- a/src/Command/Backup/BackupDeleteCommand.php +++ b/src/Command/Backup/BackupDeleteCommand.php @@ -1,33 +1,41 @@ setName('backup:delete') - ->setDescription('Delete an environment backup') ->addArgument('backup', InputArgument::OPTIONAL, 'The ID of the backup. Required in non-interactive mode.'); - $this->addProjectOption() - ->addEnvironmentOption() - ->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); - $environment = $this->getSelectedEnvironment(); - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); + $selection = $this->selector->getSelection($input); + $environment = $selection->getEnvironment(); if ($id = $input->getArgument('backup')) { $backup = $environment->getBackup($id); @@ -51,11 +59,11 @@ protected function execute(InputInterface $input, OutputInterface $output) $byId[$id] = $backup; $choices[$id] = $this->labelBackup($backup); } - $choice = $questionHelper->choose($choices, 'Enter a number to choose a backup to delete:', null, false); + $choice = $this->questionHelper->choose($choices, 'Enter a number to choose a backup to delete:', null, false); $backup = $byId[$choice]; } - if (!$questionHelper->confirm(sprintf('Are you sure you want to delete the backup %s?', $this->labelBackup($backup)))) { + if (!$this->questionHelper->confirm(sprintf('Are you sure you want to delete the backup %s?', $this->labelBackup($backup)))) { return 1; } @@ -64,19 +72,16 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf('The backup %s has been deleted.', $this->labelBackup($backup))); - if ($this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); - $activityMonitor->waitMultiple($result->getActivities(), $this->getSelectedProject()); + if ($this->activityMonitor->shouldWait($input)) { + $activityMonitor = $this->activityMonitor; + $activityMonitor->waitMultiple($result->getActivities(), $selection->getProject()); } return 0; } - private function labelBackup(Backup $backup) + private function labelBackup(Backup $backup): string { - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - return sprintf('%s (%s)', $backup->id, $formatter->format($backup->created_at, 'created_at')); + return sprintf('%s (%s)', $backup->id, $this->propertyFormatter->format($backup->created_at, 'created_at')); } } diff --git a/src/Command/Backup/BackupGetCommand.php b/src/Command/Backup/BackupGetCommand.php index c1973184bc..2c75e78f7f 100644 --- a/src/Command/Backup/BackupGetCommand.php +++ b/src/Command/Backup/BackupGetCommand.php @@ -1,35 +1,41 @@ setName('backup:get') - ->setDescription('View an environment backup') ->addArgument('backup', InputArgument::OPTIONAL, 'The ID of the backup. Defaults to the most recent one.') ->addOption('property', 'P', InputOption::VALUE_REQUIRED, 'The backup property to display.'); - $this->addProjectOption() - ->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); PropertyFormatter::configureInput($this->getDefinition()); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); - $environment = $this->getSelectedEnvironment(); - - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); + $selection = $this->selector->getSelection($input); + $environment = $selection->getEnvironment(); if ($id = $input->getArgument('backup')) { $backup = $environment->getBackup($id); @@ -52,15 +58,13 @@ protected function execute(InputInterface $input, OutputInterface $output) if (!isset($default)) { $default = $backup->id; } - $choices[$id] = sprintf('%s (%s)', $backup->id, $formatter->format($backup->created_at, 'created_at')); + $choices[$id] = sprintf('%s (%s)', $backup->id, $this->propertyFormatter->format($backup->created_at, 'created_at')); } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $choice = $questionHelper->choose($choices, 'Enter a number to choose a backup:', $default); + $choice = $this->questionHelper->choose($choices, 'Enter a number to choose a backup:', $default); $backup = $byId[$choice]; } - $formatter->displayData($output, $backup->getProperties(), $input->getOption('property')); + $this->propertyFormatter->displayData($output, $backup->getProperties(), $input->getOption('property')); return 0; } diff --git a/src/Command/Backup/BackupListCommand.php b/src/Command/Backup/BackupListCommand.php index bedae9a793..e95609e8e9 100644 --- a/src/Command/Backup/BackupListCommand.php +++ b/src/Command/Backup/BackupListCommand.php @@ -1,18 +1,26 @@ */ + private array $tableHeader = [ 'created_at' => 'Created', 'id' => 'Backup ID', 'restorable' => 'Restorable', @@ -24,44 +32,42 @@ class BackupListCommand extends CommandBase 'status' => 'Status', 'updated_at' => 'Updated', ]; - private $defaultColumns = ['created_at', 'id', 'restorable']; + /** @var string[] */ + private array $defaultColumns = ['created_at', 'id', 'restorable']; + public function __construct(private readonly Api $api, private readonly Io $io, private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table) + { + parent::__construct(); + } - protected function configure() + protected function configure(): void { $this - ->setName('backup:list') - ->setAliases(['backups']) - ->setDescription('List available backups of an environment') ->addHiddenOption('limit', null, InputOption::VALUE_REQUIRED, '[Deprecated] - this option is unused') ->addHiddenOption('start', null, InputOption::VALUE_REQUIRED, '[Deprecated] - this option is unused'); Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); PropertyFormatter::configureInput($this->getDefinition()); - $this->addProjectOption() - ->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); $this->setHiddenAliases(['snapshots', 'snapshot:list']); $this->addExample('Display backups including the "live" and "commit_id" columns', '-c+live,commit_id'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->warnAboutDeprecatedOptions(['limit', 'start']); - $this->validateInput($input); - - $environment = $this->getSelectedEnvironment(); + $this->io->warnAboutDeprecatedOptions(['limit', 'start']); + $selection = $this->selector->getSelection($input); - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); + $environment = $selection->getEnvironment(); - $backups = $environment->getBackups($input->getOption('limit')); + $backups = $environment->getBackups((int) $input->getOption('limit')); if (!$backups) { $this->stdErr->writeln('No backups found'); return 1; } - $table->replaceDeprecatedColumns(['created' => 'created_at', 'name' => 'id'], $input, $output); - $table->removeDeprecatedColumns(['progress', 'state', 'result'], '[deprecated]', $input, $output); + $this->table->replaceDeprecatedColumns(['created' => 'created_at', 'name' => 'id'], $input, $output); + $this->table->removeDeprecatedColumns(['progress', 'state', 'result'], '[deprecated]', $input, $output); $header = $this->tableHeader; $header['safe'] = 'Safe'; @@ -69,31 +75,31 @@ protected function execute(InputInterface $input, OutputInterface $output) $rows = []; foreach ($backups as $backup) { $rows[] = [ - 'created_at' => $formatter->format($backup->created_at, 'created_at'), - 'updated_at' => $formatter->format($backup->updated_at, 'updated_at'), - 'expires_at' => $formatter->format($backup->expires_at, 'expires_at'), + 'created_at' => $this->propertyFormatter->format($backup->created_at, 'created_at'), + 'updated_at' => $this->propertyFormatter->format($backup->updated_at, 'updated_at'), + 'expires_at' => $this->propertyFormatter->format($backup->expires_at, 'expires_at'), 'id' => new AdaptiveTableCell($backup->id, ['wrap' => false]), 'name' => $backup->id, 'commit_id' => $backup->commit_id, - 'live' => $formatter->format(!$backup->safe), - 'safe' => $formatter->format($backup->safe), - 'restorable' => $formatter->format($backup->restorable), - 'index' => $backup->index, + 'live' => $this->propertyFormatter->format(!$backup->safe), + 'safe' => $this->propertyFormatter->format($backup->safe), + 'restorable' => $this->propertyFormatter->format($backup->restorable), + 'index' => (string) $backup->index, 'status' => $backup->status, - 'automated' => $formatter->format($backup->getProperty('automated', false, false), 'automated'), + 'automated' => $this->propertyFormatter->format($backup->getProperty('automated', false, false), 'automated'), '[deprecated]' => '', ]; } - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->stdErr->writeln(sprintf( 'Backups on the project %s, environment %s:', - $this->api()->getProjectLabel($this->getSelectedProject()), - $this->api()->getEnvironmentLabel($environment) + $this->api->getProjectLabel($selection->getProject()), + $this->api->getEnvironmentLabel($environment), )); } - $table->render($rows, $header, $this->defaultColumns); + $this->table->render($rows, $header, $this->defaultColumns); return 0; } diff --git a/src/Command/Backup/BackupRestoreCommand.php b/src/Command/Backup/BackupRestoreCommand.php index 58c1cb15db..e6c0f6d64b 100644 --- a/src/Command/Backup/BackupRestoreCommand.php +++ b/src/Command/Backup/BackupRestoreCommand.php @@ -1,46 +1,65 @@ setName('backup:restore') - ->setDescription('Restore an environment backup') ->addArgument('backup', InputArgument::OPTIONAL, 'The ID of the backup. Defaults to the most recent one') ->addOption('target', null, InputOption::VALUE_REQUIRED, "The environment to restore to. Defaults to the backup's current environment") ->addOption('branch-from', null, InputOption::VALUE_REQUIRED, 'If the --target does not yet exist, this specifies the parent of the new environment') ->addOption('no-code', null, InputOption::VALUE_NONE, 'Do not restore code, only data.') ->addHiddenOption('restore-code', null, InputOption::VALUE_NONE, '[DEPRECATED] This option no longer has an effect.'); - if ($this->config()->get('api.sizing')) { + if ($this->config->getBool('api.sizing')) { $this->addOption('no-resources', null, InputOption::VALUE_NONE, "Do not override the target's existing resource settings."); - $this->addResourcesInitOption(['backup', 'parent', 'default', 'minimum']); + $this->resourcesUtil->addOption($this->getDefinition(), $this->validResourcesInitOptions); } - $this->addProjectOption() - ->addEnvironmentOption() - ->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); $this->setHiddenAliases(['environment:restore', 'snapshot:restore']); $this->addExample('Restore the most recent backup'); $this->addExample('Restore a specific backup', '92c9a4b2aa75422efb3d'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->warnAboutDeprecatedOptions(['restore-code']); + $this->io->warnAboutDeprecatedOptions(['restore-code']); - $this->validateInput($input); + $selection = $this->selector->getSelection($input); - $environment = $this->getSelectedEnvironment(); - $project = $this->getSelectedProject(); + $environment = $selection->getEnvironment(); + $project = $selection->getProject(); $backupName = $input->getArgument('backup'); if (!empty($backupName)) { @@ -51,7 +70,7 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } } else { - $this->stdErr->writeln(\sprintf('Finding the most recent backup for the environment %s', $this->api()->getEnvironmentLabel($environment))); + $this->stdErr->writeln(\sprintf('Finding the most recent backup for the environment %s', $this->api->getEnvironmentLabel($environment))); $backups = $environment->getBackups(); $this->stdErr->writeln(''); if (!$backups) { @@ -70,50 +89,45 @@ protected function execute(InputInterface $input, OutputInterface $output) // Validate the --branch-from option. $branchFrom = $input->getOption('branch-from'); - if ($branchFrom !== null && !$this->api()->getEnvironment($branchFrom, $project)) { + if ($branchFrom !== null && !$this->api->getEnvironment($branchFrom, $project)) { $this->stdErr->writeln(sprintf('Environment not found (in --branch-from): %s', $branchFrom)); return 1; } // Validate the --resources-init option. - $resourcesInit = $this->validateResourcesInitInput($input, $project); + $resourcesInit = $this->resourcesUtil->validateInput($input, $project, $this->validResourcesInitOptions); if ($resourcesInit === false) { return 1; } // Process the --target option, which does not have to be an existing environment. $target = $input->getOption('target'); - $targetEnvironment = $target !== null ? $this->api()->getEnvironment($target, $project) : $environment; + $targetEnvironment = $target !== null ? $this->api->getEnvironment($target, $project) : $environment; $targetName = $target !== null ? $target : $environment->name; $targetLabel = $targetEnvironment - ? $this->api()->getEnvironmentLabel($targetEnvironment) + ? $this->api->getEnvironmentLabel($targetEnvironment) : '' . $target . ''; - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - // Display a summary of the backup. $this->stdErr->writeln(\sprintf('Backup ID: %s', $backup->id)); - $this->stdErr->writeln(\sprintf('Created at: %s', $formatter->format($backup->created_at, 'created_at'))); + $this->stdErr->writeln(\sprintf('Created at: %s', $this->propertyFormatter->format($backup->created_at, 'created_at'))); if ($input->getOption('no-code')) { $this->stdErr->writeln('Only data, not code, will be restored.'); } $differentTarget = $backup->environment !== $targetName; if ($differentTarget) { - $original = $this->api()->getEnvironment($backup->environment, $project); - $originalLabel = $original ? $this->api()->getEnvironmentLabel($original, 'comment') : '' . $backup->environment . ''; + $original = $this->api->getEnvironment($backup->environment, $project); + $originalLabel = $original ? $this->api->getEnvironmentLabel($original, 'comment') : '' . $backup->environment . ''; $this->stdErr->writeln(\sprintf('Original environment: %s', $originalLabel)); $this->stdErr->writeln(''); - if (!$questionHelper->confirm(\sprintf('Are you sure you want to restore this backup to the environment %s?', $targetLabel))) { + if (!$this->questionHelper->confirm(\sprintf('Are you sure you want to restore this backup to the environment %s?', $targetLabel))) { return 1; } } else { $this->stdErr->writeln(''); - if (!$questionHelper->confirm('Are you sure you want to restore this backup?')) { + if (!$this->questionHelper->confirm('Are you sure you want to restore this backup?')) { return 1; } } @@ -127,12 +141,11 @@ protected function execute(InputInterface $input, OutputInterface $output) ->setBranchFrom($branchFrom) ->setRestoreCode($input->getOption('no-code') ? false : null) ->setRestoreResources($input->hasOption('no-resources') && $input->getOption('no-resources') ? false : null) - ->setResourcesInit($resourcesInit) + ->setResourcesInit($resourcesInit), ); - if ($this->shouldWait($input) && $result->countActivities()) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); + if ($this->activityMonitor->shouldWait($input) && $result->countActivities()) { + $activityMonitor = $this->activityMonitor; $success = $activityMonitor->waitMultiple($result->getActivities(), $project); if (!$success) { return 1; diff --git a/src/Command/BlueGreen/BlueGreenConcludeCommand.php b/src/Command/BlueGreen/BlueGreenConcludeCommand.php index 81c8d86bfa..ed46a40773 100644 --- a/src/Command/BlueGreen/BlueGreenConcludeCommand.php +++ b/src/Command/BlueGreen/BlueGreenConcludeCommand.php @@ -1,33 +1,48 @@ setName('blue-green:conclude') - ->setDescription('Conclude a blue/green deployment') + $this ->setHelp('Use this command to delete the old version after a blue/green deployment, and return to the default deployment flow.'); - $this->addProjectOption(); - $this->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input, false, true); - $environment = $this->getSelectedEnvironment(); + $selection = $this->selector->getSelection($input, new SelectorConfig(selectDefaultEnv: true)); + $environment = $selection->getEnvironment(); - $httpClient = $this->api()->getHttpClient(); - $data = $httpClient->get($environment->getLink('#versions'))->json(); + $httpClient = $this->api->getHttpClient(); + $response = $httpClient->get($environment->getLink('#versions')); + $data = (array) Utils::jsonDecode((string) $response->getBody(), true); if (count($data) < 2) { - $this->stdErr->writeln(sprintf('Blue/green deployments are not enabled for the environment %s.', $this->api()->getEnvironmentLabel($environment, 'error'))); + $this->stdErr->writeln(sprintf('Blue/green deployments are not enabled for the environment %s.', $this->api->getEnvironmentLabel($environment, 'error'))); return 1; } @@ -43,18 +58,15 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $questionText = sprintf('Are you sure you want to delete version %s?', $lockedVersionData['id']); - if (!$questionHelper->confirm($questionText)) { + if (!$this->questionHelper->confirm($questionText)) { return 1; } $this->stdErr->writeln(''); - $httpClient->delete($environment->getLink('#versions') . '/' . rawurlencode($lockedVersionData['id'])); + $httpClient->delete($environment->getLink('#versions') . '/' . rawurlencode((string) $lockedVersionData['id'])); $this->stdErr->writeln(sprintf('Version %s was deleted.', $lockedVersionData['id'])); - $this->stdErr->writeln(sprintf('List versions with: %s versions.', $this->config()->get('application.executable'))); + $this->stdErr->writeln(sprintf('List versions with: %s versions.', $this->config->getStr('application.executable'))); return 0; } diff --git a/src/Command/BlueGreen/BlueGreenDeployCommand.php b/src/Command/BlueGreen/BlueGreenDeployCommand.php index aba7345d6f..893ea2ed0a 100644 --- a/src/Command/BlueGreen/BlueGreenDeployCommand.php +++ b/src/Command/BlueGreen/BlueGreenDeployCommand.php @@ -1,36 +1,51 @@ setName('blue-green:deploy') - ->setDescription('Perform a blue/green deployment') + $this ->addOption('routing-percentage', null, InputOption::VALUE_REQUIRED, "Set the latest version's routing percentage", 100) ->setHelp('Use this command to deploy the latest (green) version, or otherwise change its routing percentage, during a blue/green deployment.'); - $this->addProjectOption(); - $this->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input, false, true); - $environment = $this->getSelectedEnvironment(); + $selection = $this->selector->getSelection($input, new SelectorConfig(selectDefaultEnv: true)); + $environment = $selection->getEnvironment(); - $httpClient = $this->api()->getHttpClient(); - $data = $httpClient->get($environment->getLink('#versions'))->json(); + $httpClient = $this->api->getHttpClient(); + $response = $httpClient->get($environment->getLink('#versions')); + $data = (array) Utils::jsonDecode((string) $response->getBody(), true); if (count($data) < 2) { - $this->stdErr->writeln(sprintf('Blue/green deployments are not enabled for the environment %s.', $this->api()->getEnvironmentLabel($environment, 'error'))); - $this->stdErr->writeln(sprintf('Enable blue/green first by running: %s blue-green:enable', $this->config()->get('application.executable'))); + $this->stdErr->writeln(sprintf('Blue/green deployments are not enabled for the environment %s.', $this->api->getEnvironmentLabel($environment, 'error'))); + $this->stdErr->writeln(sprintf('Enable blue/green first by running: %s blue-green:enable', $this->config->getStr('application.executable'))); return 1; } @@ -46,36 +61,33 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - $targetPercentage = rtrim($input->getOption('routing-percentage'), '%'); + $targetPercentage = rtrim((string) $input->getOption('routing-percentage'), '%'); if (!is_numeric($targetPercentage) || $targetPercentage > 100 || $targetPercentage < 0) { $this->stdErr->writeln('Invalid percentage: ' . $input->getOption('routing-percentage') . ''); return 1; } $targetPercentage = (int) $targetPercentage; - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - if ($targetPercentage === 100) { $questionText = sprintf('Are you sure you want to deploy version %s?', $latestVersionData['id']); } else { $questionText = sprintf('Are you sure you want to change the routing percentage for version %s from %d to %d?', $latestVersionData['id'], $latestVersionData['routing']['percentage'], $targetPercentage); } - if (!$questionHelper->confirm($questionText)) { + if (!$this->questionHelper->confirm($questionText)) { return 1; } $this->stdErr->writeln(''); - $httpClient->patch($environment->getLink('#versions') . '/' . rawurlencode($latestVersionData['id']), [ + $httpClient->patch($environment->getLink('#versions') . '/' . rawurlencode((string) $latestVersionData['id']), [ 'json' => [ 'routing' => ['percentage' => $targetPercentage], ], ]); if ($targetPercentage === 100) { $this->stdErr->writeln(sprintf('Version %s has now been deployed.', $latestVersionData['id'])); - $this->stdErr->writeln(sprintf('List versions with: %s versions.', $this->config()->get('application.executable'))); + $this->stdErr->writeln(sprintf('List versions with: %s versions.', $this->config->getStr('application.executable'))); } else { - $this->stdErr->writeln(sprintf('Version %s now has a routing percentage of %d.', $latestVersionData['id'] ,$targetPercentage)); + $this->stdErr->writeln(sprintf('Version %s now has a routing percentage of %d.', $latestVersionData['id'], $targetPercentage)); } return 0; diff --git a/src/Command/BlueGreen/BlueGreenEnableCommand.php b/src/Command/BlueGreen/BlueGreenEnableCommand.php index ac8e729b2c..fb9674f0ae 100644 --- a/src/Command/BlueGreen/BlueGreenEnableCommand.php +++ b/src/Command/BlueGreen/BlueGreenEnableCommand.php @@ -1,53 +1,65 @@ setName('blue-green:enable') - ->setDescription('Enable blue/green deployments') + $this ->addOption('routing-percentage', '%', InputOption::VALUE_REQUIRED, "Set the latest version's routing percentage", 100); $this->setHelp( 'Use this command to enable blue/green deployments on an environment.' . "\n\n" . 'If multiple environment versions do not already exist, this creates a new version as a copy of the current one.' . "\n\n" . '100% of traffic is routed to the current version, and 0% to the new version. This can be flipped or changed with the blue-green:deploy command.' - . "\n\n" . 'While blue/green deployments are "enabled" (while multiple versions exist), the current version is "locked", and deployments (e.g. from Git pushes) affect the new version.' + . "\n\n" . 'While blue/green deployments are "enabled" (while multiple versions exist), the current version is "locked", and deployments (e.g. from Git pushes) affect the new version.', ); - $this->addProjectOption(); - $this->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input, false, true); - $environment = $this->getSelectedEnvironment(); + $selection = $this->selector->getSelection($input, new SelectorConfig(selectDefaultEnv: true)); + $environment = $selection->getEnvironment(); - $httpClient = $this->api()->getHttpClient(); - $data = $httpClient->get($environment->getLink('#versions'))->json(); + $httpClient = $this->api->getHttpClient(); + $response = $httpClient->get($environment->getLink('#versions')); + $data = (array) Utils::jsonDecode((string) $response->getBody(), true); if (count($data) > 1) { - $this->stdErr->writeln(sprintf('Blue/green deployments are already enabled for the environment %s.', $this->api()->getEnvironmentLabel($environment))); - $this->stdErr->writeln(sprintf('List versions by running: %s versions', $this->config()->get('application.executable'))); + $this->stdErr->writeln(sprintf('Blue/green deployments are already enabled for the environment %s.', $this->api->getEnvironmentLabel($environment))); + $this->stdErr->writeln(sprintf('List versions by running: %s versions', $this->config->getStr('application.executable'))); return 0; } - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - if (!$questionHelper->confirm(sprintf('Are you sure you want to enable blue/green deployments for the environment %s?', $this->api()->getEnvironmentLabel($environment)))) { + if (!$this->questionHelper->confirm(sprintf('Are you sure you want to enable blue/green deployments for the environment %s?', $this->api->getEnvironmentLabel($environment)))) { return 1; } $this->stdErr->writeln(''); $httpClient->post($environment->getLink('#versions'), ['json' => new \stdClass()]); - $this->stdErr->writeln(sprintf('Blue/green deployments are now enabled for the environment %s.', $this->api()->getEnvironmentLabel($environment))); + $this->stdErr->writeln(sprintf('Blue/green deployments are now enabled for the environment %s.', $this->api->getEnvironmentLabel($environment))); return 0; } diff --git a/src/Command/BotCommand.php b/src/Command/BotCommand.php index 5ad7a6d639..e18fd0c136 100644 --- a/src/Command/BotCommand.php +++ b/src/Command/BotCommand.php @@ -1,29 +1,36 @@ setName('bot') - ->setDescription('The ' . $this->config()->get('service.name') . ' Bot') + $this ->addOption('party', null, InputOption::VALUE_NONE) ->addOption('parrot', null, InputOption::VALUE_NONE); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $dir = CLI_ROOT . '/resources/bot'; - $signature = $this->config()->get('service.name'); + $signature = $this->config->getStr('service.name'); $party = $input->getOption('party'); $interval = $party ? 120000 : 500000; @@ -34,10 +41,15 @@ protected function execute(InputInterface $input, OutputInterface $output) $signature = ''; } + $files = scandir($dir); + if (!$files) { + throw new \RuntimeException('Failed to read directory: ' . $dir); + } + $frames = []; - foreach (scandir($dir) as $filename) { + foreach ($files as $filename) { if ($filename[0] !== '.') { - $frames[] = file_get_contents($dir . '/' . $filename); + $frames[] = (string) file_get_contents($dir . '/' . $filename); } } @@ -53,14 +65,13 @@ protected function execute(InputInterface $input, OutputInterface $output) if (!$output->isDecorated()) { $animation->render(); - return; + return 0; } // Stay positive: return code 0 when the user quits. if (function_exists('pcntl_signal')) { - declare(ticks = 1); - /** @noinspection PhpComposerExtensionStubsInspection */ - pcntl_signal(SIGINT, function () { + declare(ticks=1); + pcntl_signal(SIGINT, function (): void { echo "\n"; exit; }); @@ -71,20 +82,27 @@ protected function execute(InputInterface $input, OutputInterface $output) } } - private function addSignature(array $frames, $signature) + /** + * @param array $frames + * @param string $signature + * @return string[] + */ + private function addSignature(array $frames, string $signature): array { $indent = ' '; if (strlen($signature) > 0) { - $signatureIndent = str_repeat(' ', strlen($indent) + 5 - floor(strlen($signature) / 2)); + $signatureIndent = str_repeat(' ', (int) (strlen($indent) + 5 - floor(strlen($signature) / 2))); $signature = "\n" . $signatureIndent . '' . $signature . ''; } - return array_map(function ($frame) use ($indent, $signature) { - return preg_replace('/^/m', $indent, $frame) . $signature; - }, $frames); + return array_map(fn($frame) => preg_replace('/^/m', $indent, (string) $frame) . $signature, $frames); } - private function addColor(array $frames) + /** + * @param string[] $frames + * @return string[] + */ + private function addColor(array $frames): array { $colors = ['red', 'yellow', 'green', 'blue', 'magenta', 'cyan', 'white']; $partyFrames = []; diff --git a/src/Command/Certificate/CertificateAddCommand.php b/src/Command/Certificate/CertificateAddCommand.php index 7828fe0c31..24b033e9ea 100644 --- a/src/Command/Certificate/CertificateAddCommand.php +++ b/src/Command/Certificate/CertificateAddCommand.php @@ -1,31 +1,40 @@ setName('certificate:add') - ->setDescription('Add an SSL certificate to the project') ->addOption('cert', null, InputOption::VALUE_REQUIRED, 'The path to the certificate file') ->addOption('key', null, InputOption::VALUE_REQUIRED, 'The path to the certificate private key file') ->addOption('chain', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'The path to the certificate chain file'); - $this->addProjectOption(); - $this->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); - $project = $this->getSelectedProject(); + $selection = $this->selector->getSelection($input); + $project = $selection->getProject(); $certPath = $input->getOption('cert'); $keyPath = $input->getOption('key'); @@ -39,9 +48,8 @@ protected function execute(InputInterface $input, OutputInterface $output) $result = $project->addCertificate($options['certificate'], $options['key'], $options['chain']); - if ($this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); + if ($this->activityMonitor->shouldWait($input)) { + $activityMonitor = $this->activityMonitor; $activityMonitor->waitMultiple($result->getActivities(), $project); } diff --git a/src/Command/Certificate/CertificateDeleteCommand.php b/src/Command/Certificate/CertificateDeleteCommand.php index 3ab6a41801..7d7d30c8e9 100644 --- a/src/Command/Certificate/CertificateDeleteCommand.php +++ b/src/Command/Certificate/CertificateDeleteCommand.php @@ -1,57 +1,66 @@ setName('certificate:delete') - ->setDescription('Delete a certificate from the project') ->addArgument('id', InputArgument::REQUIRED, 'The certificate ID (or the start of it)'); - $this->addProjectOption(); - $this->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); } /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); + $selection = $this->selector->getSelection($input); $id = $input->getArgument('id'); - $project = $this->getSelectedProject(); + $project = $selection->getProject(); $certificate = $project->getCertificate($id); if (!$certificate) { try { - $certificate = $this->api()->matchPartialId($id, $project->getCertificates(), 'Certificate'); + /** @var Certificate $certificate */ + $certificate = $this->api->matchPartialId($id, $project->getCertificates(), 'Certificate'); } catch (\InvalidArgumentException $e) { $this->stdErr->writeln($e->getMessage()); return 1; } } - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - if (!$questionHelper->confirm(sprintf('Are you sure you want to delete the certificate %s?', $certificate->id))) { + if (!$this->questionHelper->confirm(sprintf('Are you sure you want to delete the certificate %s?', $certificate->id))) { return 1; } try { $result = $certificate->delete(); } catch (BadResponseException $e) { - if (($response = $e->getResponse()) && $response->getStatusCode() === 403 && $certificate->is_provisioned) { + if ($e->getResponse()->getStatusCode() === 403 && $certificate->is_provisioned) { $this->stdErr->writeln(sprintf('The certificate %s is automatically provisioned; it cannot be deleted.', $certificate->id)); return 1; } @@ -61,9 +70,8 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(sprintf('The certificate %s has been deleted.', $certificate->id)); - if ($this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); + if ($this->activityMonitor->shouldWait($input)) { + $activityMonitor = $this->activityMonitor; $activityMonitor->waitMultiple($result->getActivities(), $project); } diff --git a/src/Command/Certificate/CertificateGetCommand.php b/src/Command/Certificate/CertificateGetCommand.php index 0ae430d140..1010676cf2 100644 --- a/src/Command/Certificate/CertificateGetCommand.php +++ b/src/Command/Certificate/CertificateGetCommand.php @@ -1,47 +1,55 @@ setName('certificate:get') - ->setDescription('View a certificate') ->addArgument('id', InputArgument::REQUIRED, 'The certificate ID (or the start of it)') ->addOption('property', 'P', InputOption::VALUE_REQUIRED, 'The certificate property to view'); PropertyFormatter::configureInput($this->getDefinition()); - $this->addProjectOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); - $project = $this->getSelectedProject(); + $selection = $this->selector->getSelection($input); + $project = $selection->getProject(); $id = $input->getArgument('id'); $cert = $project->getCertificate($id); if (!$cert) { try { - $cert = $this->api()->matchPartialId($id, $project->getCertificates(), 'Certificate'); + /** @var Certificate $cert */ + $cert = $this->api->matchPartialId($id, $project->getCertificates(), 'Certificate'); } catch (\InvalidArgumentException $e) { $this->stdErr->writeln($e->getMessage()); return 1; } } - /** @var PropertyFormatter $propertyFormatter */ - $propertyFormatter = $this->getService('property_formatter'); - - $propertyFormatter->displayData($output, $cert->getProperties(), $input->getOption('property')); + $this->propertyFormatter->displayData($output, $cert->getProperties(), $input->getOption('property')); return 0; } diff --git a/src/Command/Certificate/CertificateListCommand.php b/src/Command/Certificate/CertificateListCommand.php index 83a09bf915..b9f6ff0fb0 100644 --- a/src/Command/Certificate/CertificateListCommand.php +++ b/src/Command/Certificate/CertificateListCommand.php @@ -1,30 +1,39 @@ */ + private array $tableHeader = [ 'id' => 'ID', 'domains' => 'Domain(s)', 'created' => 'Created', 'expires' => 'Expires', 'issuer' => 'Issuer', ]; + public function __construct(private readonly Api $api, private readonly Config $config, private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table) + { + parent::__construct(); + } - protected function configure() + protected function configure(): void { - $this - ->setName('certificate:list') - ->setAliases(['certificates', 'certs']) - ->setDescription('List project certificates'); $this->addOption('domain', null, InputOption::VALUE_REQUIRED, 'Filter by domain name (case-insensitive search)'); $this->addOption('exclude-domain', null, InputOption::VALUE_REQUIRED, 'Exclude certificates, matching by domain name (case-insensitive search)'); $this->addOption('issuer', null, InputOption::VALUE_REQUIRED, 'Filter by issuer'); @@ -36,13 +45,14 @@ protected function configure() $this->addOption('pipe-domains', null, InputOption::VALUE_NONE, 'Only return a list of domain names covered by the certificates'); PropertyFormatter::configureInput($this->getDefinition()); Table::configureInput($this->getDefinition(), $this->tableHeader); - $this->addProjectOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); $this->addExample('Output a list of domains covered by valid certificates', '--pipe-domains --no-expired'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); + $selection = $this->selector->getSelection($input); // Set --no-expired by default, if --ignore-expiry and --only-expired // are not supplied. @@ -53,7 +63,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $filterOptions = ['domain', 'exclude-domain', 'issuer', 'only-auto', 'no-auto', 'only-expired', 'no-expired']; $filters = array_filter(array_intersect_key($input->getOptions(), array_flip($filterOptions))); - $project = $this->getSelectedProject(); + $project = $selection->getProject(); $certs = $project->getCertificates(); @@ -83,49 +93,49 @@ protected function execute(InputInterface $input, OutputInterface $output) return 0; } - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - /** @var \Platformsh\Cli\Service\PropertyFormatter $propertyFormatter */ - $propertyFormatter = $this->getService('property_formatter'); - $rows = []; foreach ($certs as $cert) { $rows[] = [ 'id' => $cert->id, 'domains' => implode("\n", $cert->domains), - 'created' => $propertyFormatter->format($cert->created_at, 'created_at'), - 'expires' => $propertyFormatter->format($cert->expires_at, 'expires_at'), + 'created' => $this->propertyFormatter->format($cert->created_at, 'created_at'), + 'expires' => $this->propertyFormatter->format($cert->expires_at, 'expires_at'), 'issuer' => $this->getCertificateIssuerByAlias($cert, 'commonName') ?: '', ]; } - if (!$table->formatIsMachineReadable()) { - $this->stdErr->writeln(sprintf('Certificates for the project %s:', $this->api()->getProjectLabel($project))); + if (!$this->table->formatIsMachineReadable()) { + $this->stdErr->writeln(sprintf('Certificates for the project %s:', $this->api->getProjectLabel($project))); } - $table->render($rows, $this->tableHeader); + $this->table->render($rows, $this->tableHeader); - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf( 'To view a single certificate, run: %s certificate:get ', - $this->config()->get('application.executable') + $this->config->getStr('application.executable'), )); } return 0; } - protected function filterCerts(array &$certs, array $filters) + /** + * @param Certificate[] $certs + * @param array $filters + * @return void + */ + protected function filterCerts(array &$certs, array $filters): void { foreach ($filters as $filter => $value) { switch ($filter) { case 'domain': case 'exclude-domain': $include = $filter === 'domain'; - $certs = array_filter($certs, function (Certificate $cert) use ($value, $include) { + $certs = array_filter($certs, function (Certificate $cert) use ($value, $include): bool { foreach ($cert->domains as $domain) { - if (stripos($domain, $value) !== false) { + if (stripos($domain, (string) $value) !== false) { return $include; } } @@ -135,7 +145,7 @@ protected function filterCerts(array &$certs, array $filters) break; case 'issuer': - $certs = array_filter($certs, function (Certificate $cert) use ($value) { + $certs = array_filter($certs, function (Certificate $cert) use ($value): bool { foreach ($cert->issuer as $issuer) { if (isset($issuer['value']) && $issuer['value'] === $value) { return true; @@ -147,27 +157,19 @@ protected function filterCerts(array &$certs, array $filters) break; case 'only-auto': - $certs = array_filter($certs, function (Certificate $cert) { - return (bool) $cert->is_provisioned; - }); + $certs = array_filter($certs, fn(Certificate $cert): bool => $cert->is_provisioned); break; case 'no-auto': - $certs = array_filter($certs, function (Certificate $cert) { - return !$cert->is_provisioned; - }); + $certs = array_filter($certs, fn(Certificate $cert): bool => !$cert->is_provisioned); break; case 'no-expired': - $certs = array_filter($certs, function (Certificate $cert) { - return !$this->isExpired($cert); - }); + $certs = array_filter($certs, fn(Certificate $cert): bool => !$this->isExpired($cert)); break; case 'only-expired': - $certs = array_filter($certs, function (Certificate $cert) { - return $this->isExpired($cert); - }); + $certs = array_filter($certs, fn(Certificate $cert): bool => $this->isExpired($cert)); break; } } @@ -176,22 +178,17 @@ protected function filterCerts(array &$certs, array $filters) /** * Check if a certificate has expired. * - * @param \Platformsh\Client\Model\Certificate $cert + * @param Certificate $cert * * @return bool */ - private function isExpired(Certificate $cert) + private function isExpired(Certificate $cert): bool { return time() >= strtotime($cert->expires_at); } - /** - * @param \Platformsh\Client\Model\Certificate $cert - * @param string $alias - * - * @return string|bool - */ - protected function getCertificateIssuerByAlias(Certificate $cert, $alias) { + private function getCertificateIssuerByAlias(Certificate $cert, string $alias): string|false + { foreach ($cert->issuer as $issuer) { if (isset($issuer['alias'], $issuer['value']) && $issuer['alias'] === $alias) { return $issuer['value']; diff --git a/src/Command/ClearCacheCommand.php b/src/Command/ClearCacheCommand.php index 82e35caa3a..aa18ae2290 100644 --- a/src/Command/ClearCacheCommand.php +++ b/src/Command/ClearCacheCommand.php @@ -1,28 +1,32 @@ setName('clear-cache') - ->setAliases(['cc']) - ->setHiddenAliases(['clearcache']) - ->setDescription('Clear the CLI cache'); + ->setHiddenAliases(['clearcache']); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - /** @var \Doctrine\Common\Cache\CacheProvider $cache */ - $cache = $this->getService('cache'); + $cache = $this->cacheProvider; $cache->flushAll(); $this->stdErr->writeln("All caches have been cleared"); + return 0; } } diff --git a/src/Command/CommandBase.php b/src/Command/CommandBase.php index 0cb57243a8..55b54d2a90 100644 --- a/src/Command/CommandBase.php +++ b/src/Command/CommandBase.php @@ -1,1847 +1,119 @@ hiddenInList - || !in_array($this->stability, [self::STABILITY_STABLE, self::STABILITY_BETA]) - || $this->config()->isCommandHidden($this->getName()); - } - - /** - * @inheritdoc - */ - protected function initialize(InputInterface $input, OutputInterface $output) - { - // Set up dependencies that are only needed once per command run. - $this->output = $output; - $this->container()->set('output', $output); - $this->input = $input; - $this->container()->set('input', $input); - $this->stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; - - // Clear cache properties, in case this object is being reused with - // separate input. - $this->project = null; - $this->environment = null; - $this->remoteContainer = null; - - $this->promptLegacyMigrate(); - - if (!self::$printedApiTokenWarning && $this->onContainer() && (getenv($this->config()->get('application.env_prefix') . 'TOKEN') || $this->api()->hasApiToken(false))) { - $this->stdErr->writeln('Warning:'); - $this->stdErr->writeln('An API token is set. Anyone with SSH access to this environment can read the token.'); - $this->stdErr->writeln('Please ensure the token only has strictly necessary access.'); - $this->stdErr->writeln(''); - self::$printedApiTokenWarning = true; - } - } - - /** - * Set up the API object. - * - * @return \Platformsh\Cli\Service\Api - */ - protected function api() - { - if (!isset($this->api)) { - $this->api = $this->getService('api'); - } - if (!$this->apiHasListeners && $this->output && $this->input) { - $this->api - ->dispatcher - ->addListener('login_required', [$this, 'login']); - if ($this->config()->get('application.drush_aliases')) { - $this->api - ->dispatcher - ->addListener('environments_changed', [$this, 'updateDrushAliases']); - } - $this->apiHasListeners = true; - } - - return $this->api; - } - - /** - * Detects if the command is running on an application container. - * - * @return bool - */ - private function onContainer() { - $envPrefix = $this->config()->get('service.env_prefix'); - return getenv($envPrefix . 'PROJECT') !== false - && getenv($envPrefix . 'BRANCH') !== false - && getenv($envPrefix . 'TREE_ID') !== false; - } - - /** - * Prompt the user to migrate from the legacy project file structure. - * - * If the input is interactive, the user will be asked to migrate up to once - * per hour. The time they were last asked will be stored in the project - * configuration. If the input is not interactive, the user will be warned - * (on every command run) that they should run the 'legacy-migrate' command. - */ - private function promptLegacyMigrate() - { - static $asked = false; - /** @var \Platformsh\Cli\Local\LocalProject $localProject */ - $localProject = $this->getService('local.project'); - if ($localProject->getLegacyProjectRoot() && $this->getName() !== 'legacy-migrate' && !$asked) { - $asked = true; - - $projectRoot = $this->getProjectRoot(); - $timestamp = time(); - $promptMigrate = true; - if ($projectRoot) { - $projectConfig = $localProject->getProjectConfig($projectRoot); - if (isset($projectConfig['migrate']['3.x']['last_asked']) - && $projectConfig['migrate']['3.x']['last_asked'] > $timestamp - 3600) { - $promptMigrate = false; - } - } - - $this->stdErr->writeln(sprintf( - 'You are in a project using an old file structure, from previous versions of the %s.', - $this->config()->get('application.name') - )); - if ($this->input->isInteractive() && $promptMigrate) { - if ($projectRoot && isset($projectConfig)) { - $projectConfig['migrate']['3.x']['last_asked'] = $timestamp; - $localProject->writeCurrentProjectConfig($projectConfig, $projectRoot); - } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - if ($questionHelper->confirm('Migrate to the new structure?')) { - $code = $this->runOtherCommand('legacy-migrate'); - exit($code); - } - } else { - $this->stdErr->writeln(sprintf( - 'Fix this with: %s legacy-migrate', - $this->config()->get('application.executable') - )); - } - $this->stdErr->writeln(''); - } - } - - /** - * {@inheritdoc} - */ - protected function interact(InputInterface $input, OutputInterface $output) - { - // Work around a bug in Console which means the default command's input - // is always considered to be interactive. - if ($this->getName() === 'welcome' - && isset($GLOBALS['argv']) - && array_intersect($GLOBALS['argv'], ['-n', '--no', '-y', '---yes'])) { - $input->setInteractive(false); - return; - } - - $this->checkUpdates(); - $this->checkSelfInstall(); - // Run migration steps if configured. - if ($this->config()->getWithDefault('migrate.prompt', false)) { - $this->promptDeleteOldCli(); - $this->checkMigrateToNewCLI(); - } - } - - /** - * Check for self-installation. - */ - protected function checkSelfInstall() - { - // Avoid checking more than once in this process. - if (self::$checkedSelfInstall) { - return; - } - self::$checkedSelfInstall = true; - - $config = $this->config(); - - // Avoid if disabled. - if (!$config->getWithDefault('application.prompt_self_install', true) - || !$config->isCommandEnabled('self:install')) { - return; - } - - // Avoid if already installed. - if (file_exists($config->getUserConfigDir() . DIRECTORY_SEPARATOR . SelfInstallCommand::INSTALLED_FILENAME)) { - return; - } - - // Avoid if other CLIs are installed. - if ($this->isWrapped() || $this->otherCLIsInstalled()) { - return; - } - - // Stop if already prompted and declined. - /** @var \Platformsh\Cli\Service\State $state */ - $state = $this->getService('state'); - if ($state->get('self_install.last_prompted') !== false) { - return; - } - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $this->stdErr->writeln('CLI resource files can be installed automatically. They provide support for autocompletion and other features.'); - $questionText = 'Do you want to install these files?'; - if (file_exists($config->getUserConfigDir() . DIRECTORY_SEPARATOR . '/shell-config.rc')) { - $questionText = 'Do you want to complete the installation?'; - } - $answer = $questionHelper->confirm($questionText); - $state->set('self_install.last_prompted', time()); - $this->stdErr->writeln(''); - - if ($answer) { - $this->runOtherCommand('self:install'); - } else { - $this->stdErr->writeln('To install at another time, run: ' . $config->get('application.executable') . ' self:install'); - } - - $this->stdErr->writeln(''); - } - - /** - * Check for updates. - */ - protected function checkUpdates() - { - // Avoid checking more than once in this process. - if (self::$checkedUpdates) { - return; - } - self::$checkedUpdates = true; - - // Check that the Phar extension is available. - if (!extension_loaded('Phar')) { - return; - } - - // Get the filename of the Phar, or stop if this instance of the CLI is - // not a Phar. - $pharFilename = \Phar::running(false); - if (!$pharFilename) { - return; - } - - // Check if the file and its containing directory are writable. - if (!is_writable($pharFilename) || !is_writable(dirname($pharFilename))) { - return; - } - - // Check if updates are configured. - $config = $this->config(); - if (!$config->getWithDefault('updates.check', true)) { - return; - } - - // Determine an embargo time, after which updates can be checked. - $timestamp = time(); - $embargoTime = $timestamp - (int) $config->getWithDefault('updates.check_interval', 604800); - - // Stop if updates were last checked after the embargo time. - /** @var \Platformsh\Cli\Service\State $state */ - $state = $this->getService('state'); - if ($state->get('updates.last_checked') > $embargoTime) { - return; - } - - // Stop if the Phar was updated after the embargo time. - if (filemtime($pharFilename) > $embargoTime) { - return; - } - - // Ensure classes are auto-loaded if they may be needed after the - // update. - $this->getService('question_helper'); - $this->getService('shell'); - $currentVersion = $this->config()->getVersion(); - - /** @var \Platformsh\Cli\Service\SelfUpdater $cliUpdater */ - $cliUpdater = $this->getService('self_updater'); - $cliUpdater->setAllowMajor(true); - $cliUpdater->setTimeout(5); - - try { - $newVersion = $cliUpdater->update(null, $currentVersion); - } catch (\RuntimeException $e) { - if (strpos($e->getMessage(), 'Failed to download') !== false) { - $this->stdErr->writeln('' . $e->getMessage() . ''); - $newVersion = false; - } else { - throw $e; - } - } - - $state->set('updates.last_checked', $timestamp); - - if ($newVersion === '') { - // No update was available. - return; - } - - if ($newVersion !== false) { - // Update succeeded. Continue (based on a few conditions). - $this->continueAfterUpdating($currentVersion, $newVersion, $pharFilename); - exit(0); - } - - // Automatic update failed. - // Error messages will already have been printed, and the original - // command can continue. - $this->stdErr->writeln(''); - } - - private function cliPath() - { - $thisPath = CLI_ROOT . '/bin/platform'; - if (defined('CLI_FILE')) { - $thisPath = CLI_FILE; - } - if (extension_loaded('Phar') && ($pharPath = \Phar::running(false))) { - $thisPath = $pharPath; - } - return $thisPath; - } - - /** - * Returns whether other instances are installed of the CLI. - * - * Finds programs with the same executable name in the PATH. - * - * @return bool - */ - private function otherCLIsInstalled() - { - static $otherPaths; - if ($otherPaths === null) { - $thisPath = $this->cliPath(); - $paths = (new OsUtil())->findExecutables($this->config()->get('application.executable')); - $otherPaths = array_unique(array_filter($paths, function ($p) use ($thisPath) { - $realpath = realpath($p); - return $realpath && $realpath !== $thisPath; - })); - if (!empty($otherPaths)) { - $this->debug('Other CLI(s) found: ' . implode(", ", $otherPaths)); - } - } - return !empty($otherPaths); - } - - /** - * Check if both CLIs are installed to prompt the user to delete the old one. - */ - private function promptDeleteOldCli() - { - // Avoid checking more than once in this process. - if (self::$promptedDeleteOldCli) { - return; - } - self::$promptedDeleteOldCli = true; - - if ($this->isWrapped() || !$this->otherCLIsInstalled()) { - return; - } - $pharPath = \Phar::running(false); - if (!$pharPath || !is_file($pharPath) || !is_writable($pharPath)) { - return; - } - - // Avoid deleting random directories in path - $legacyDir = dirname(dirname($pharPath)); - if ($legacyDir !== $this->config()->getUserConfigDir()) { - return; - } - - $message = "\nWarning: Multiple CLI instances are installed." - . "\nThis is probably due to migration between the Legacy CLI and the new CLI." - . "\nIf so, delete this (Legacy) CLI instance to complete the migration." - . "\n" - . "\nRemove the following file completely: $pharPath" - . "\nThis operation is safe and doesn't delete any data." - . "\n"; - $this->stdErr->writeln($message); - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - if ($questionHelper->confirm('Do you want to remove this file now?')) { - if (unlink($pharPath)) { - $this->stdErr->writeln('File successfully removed! Open a new terminal for the changes to take effect.'); - // Exit because no further Phar classes can be loaded. - // This uses a non-zero code because the original command - // technically failed. - exit(1); - } else { - $this->stdErr->writeln('Error: Failed to delete the file.'); - } - $this->stdErr->writeln(''); - } - } - - /** - * Check for migration to the new CLI. - */ - protected function checkMigrateToNewCLI() - { - // Avoid checking more than once in this process. - if (self::$checkedMigrate) { - return; - } - self::$checkedMigrate = true; - - // Avoid if running within the new CLI or within a CI. - if ($this->isWrapped() || $this->isCI()) { - return; - } - - $config = $this->config(); - - // Prompt the user to migrate at most once every 24 hours. - $now = time(); - $embargoTime = $now - $config->getWithDefault('migrate.prompt_interval', 60 * 60 * 24); - $state = $this->getService('state'); - if ($state->get('migrate.last_prompted') > $embargoTime) { - return; - } - - $message = "Warning:" - . "\nRunning the CLI directly under PHP is now referred to as the \"Legacy CLI\", and is no longer recommended."; - if ($config->has('migrate.docs_url')) { - $message .= "\nInstall the latest release for your operating system by following these instructions: " - . "\n" . $config->get('migrate.docs_url'); - } - $message .= "\n"; - $this->stdErr->writeln($message); - $state->set('migrate.last_prompted', time()); - } - - /** - * Prompts the user to continue with the original command after updating. - * - * This only applies if it's not a major version change. - * - * @param string $currentVersion - * @param string $newVersion - * @param string $pharFilename - * - * @return void - */ - private function continueAfterUpdating($currentVersion, $newVersion, $pharFilename) { - if (!isset($this->input) || !$this->input instanceof ArgvInput || !is_executable($pharFilename)) { - return; - } - list($currentMajorVersion,) = explode('.', ltrim($currentVersion, 'v'), 2); - list($newMajorVersion,) = explode('.', ltrim($newVersion, 'v'), 2); - if ($newMajorVersion !== $currentMajorVersion) { - return; - } - - /** @var \Platformsh\Cli\Service\Shell $shell */ - $shell = $this->getService('shell'); - - $originalCommand = $this->input->__toString(); - if (empty($originalCommand)) { - $exitCode = $shell->executeSimple(escapeshellarg($pharFilename)); - exit($exitCode); - } - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $questionText = "\n" - . 'Original command: ' . $originalCommand . '' - . "\n\n" . 'Continue?'; - if ($questionHelper->confirm($questionText)) { - $this->stdErr->writeln(''); - $exitCode = $shell->executeSimple(escapeshellarg($pharFilename) . ' ' . $originalCommand); - exit($exitCode); - } - } - - /** - * Log in the user. - * - * This is called via the 'login_required' event. - * - * @param LoginRequiredEvent $event - * @see Api::getClient() - */ - public function login(LoginRequiredEvent $event) - { - $success = false; - if ($this->output && $this->input && $this->input->isInteractive()) { - $sessionAdvice = []; - if ($this->config()->getSessionId() !== 'default' || count($this->api()->listSessionIds()) > 1) { - $sessionAdvice[] = sprintf('The current session ID is: %s', $this->config()->getSessionId()); - if (!$this->config()->isSessionIdFromEnv()) { - $sessionAdvice[] = sprintf('To switch sessions, run: %s session:switch', $this->config()->get('application.executable')); - } - } - - if ($this->config()->getWithDefault('application.login_method', 'browser') === 'browser') { - /** @var \Platformsh\Cli\Service\Url $url */ - $urlService = $this->getService('url'); - if ($urlService->canOpenUrls()) { - $this->stdErr->writeln($event->getMessage()); - $this->stdErr->writeln(''); - if ($sessionAdvice) { - $this->stdErr->writeln($sessionAdvice); - $this->stdErr->writeln(''); - } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - if ($questionHelper->confirm('Log in via a browser?')) { - $this->stdErr->writeln(''); - $exitCode = $this->runOtherCommand('auth:browser-login', $event->getLoginOptions()); - $this->stdErr->writeln(''); - $success = $exitCode === 0; - } - } - } - } - if (!$success) { - $e = new LoginRequiredException(); - $e->setMessageFromEvent($event); - throw $e; - } - } - - /** - * Is this a local command? (if it does not make API requests) - * - * @return bool - */ - public function isLocal() - { - return $this->local; - } - - /** - * Get the current project if the user is in a project directory. - * - * @param bool $suppressErrors Suppress 403 or not found errors. - * - * @throws \RuntimeException - * - * @return Project|false The current project - */ - public function getCurrentProject($suppressErrors = false) - { - if (isset($this->currentProject)) { - return $this->currentProject; - } - if (!$projectRoot = $this->getProjectRoot()) { - return false; - } - - $project = false; - /** @var \Platformsh\Cli\Local\LocalProject $localProject */ - $localProject = $this->getService('local.project'); - $config = $localProject->getProjectConfig($projectRoot); - if ($config) { - $this->debug('Project "' . $config['id'] . '" is mapped to the current directory'); - try { - $project = $this->api()->getProject($config['id'], isset($config['host']) ? $config['host'] : null); - } catch (BadResponseException $e) { - if ($suppressErrors && $e->getResponse() && in_array($e->getResponse()->getStatusCode(), [403, 404])) { - return $this->currentProject = false; - } - if ($this->config()->has('api.base_url') - && $e->getResponse() && $e->getResponse()->getStatusCode() === 401 - && parse_url($this->config()->get('api.base_url'), PHP_URL_HOST) !== $e->getRequest()->getHost()) { - $this->debug('Ignoring 401 error for unrecognized local project hostname: ' . $e->getRequest()->getHost()); - return $this->currentProject = false; - } - throw $e; - } - if (!$project) { - if ($suppressErrors) { - return $this->currentProject = false; - } - throw new ProjectNotFoundException( - "Project not found: " . $config['id'] - . "\nEither you do not have access to the project or it no longer exists." - ); - } - } - $this->currentProject = $project; - - return $project; - } - - /** - * Get the current environment if the user is in a project directory. - * - * @param Project $expectedProject The expected project. - * @param bool|null $refresh Whether to refresh the environments or projects - * cache. - * - * @return Environment|false The current environment. - */ - public function getCurrentEnvironment(Project $expectedProject = null, $refresh = null) - { - if (!($projectRoot = $this->getProjectRoot()) - || !($project = $this->getCurrentProject(true)) - || ($expectedProject !== null && $expectedProject->id !== $project->id)) { - return false; - } - - /** @var \Platformsh\Cli\Service\Git $git */ - $git = $this->getService('git'); - $git->setDefaultRepositoryDir($this->getProjectRoot()); - /** @var \Platformsh\Cli\Local\LocalProject $localProject */ - $localProject = $this->getService('local.project'); - $config = $localProject->getProjectConfig($projectRoot); - - // Check if there is a manual mapping set for the current branch. - if (!empty($config['mapping']) - && ($currentBranch = $git->getCurrentBranch()) - && !empty($config['mapping'][$currentBranch])) { - $environment = $this->api()->getEnvironment($config['mapping'][$currentBranch], $project, $refresh); - if ($environment) { - $this->debug('Found mapped environment for branch "' . $currentBranch . '": ' . $this->api()->getEnvironmentLabel($environment)); - return $environment; - } else { - unset($config['mapping'][$currentBranch]); - $localProject->writeCurrentProjectConfig($config, $projectRoot); - } - } - - // Check whether the user has a Git upstream set to a remote environment - // ID. - $upstream = $git->getUpstream(); - if ($upstream && strpos($upstream, '/') !== false) { - list(, $potentialEnvironment) = explode('/', $upstream, 2); - $environment = $this->api()->getEnvironment($potentialEnvironment, $project, $refresh); - if ($environment) { - $this->debug('Selecting environment "' . $environment->id . '" based on Git upstream: ' . $upstream); - return $environment; - } - } - - // There is no Git remote set. Fall back to trying the current branch - // name. - if (!empty($currentBranch) || ($currentBranch = $git->getCurrentBranch())) { - $environment = $this->api()->getEnvironment($currentBranch, $project, $refresh); - if (!$environment) { - // Try a sanitized version of the branch name too. - $currentBranchSanitized = Environment::sanitizeId($currentBranch); - if ($currentBranchSanitized !== $currentBranch) { - $environment = $this->api()->getEnvironment($currentBranchSanitized, $project, $refresh); - } - } - if ($environment) { - $this->debug('Selecting environment "' . $environment->id . '" based on branch name: ' . $currentBranch); - return $environment; - } - $this->debug('No environment was found to match the current Git branch: ' . $currentBranch); - } - - return false; - } - - /** - * Update the user's local Drush aliases. - * - * This is called via the 'environments_changed' event. - * - * @see \Platformsh\Cli\Service\Api::getEnvironments() - * - * @param EnvironmentsChangedEvent $event - */ - public function updateDrushAliases(EnvironmentsChangedEvent $event) - { - $projectRoot = $this->getProjectRoot(); - if (!$projectRoot) { - return; - } - // Make sure the local:drush-aliases command is enabled. - if (!$this->getApplication()->has('local:drush-aliases')) { - return; - } - // Double-check that the passed project is the current one, and that it - // still exists. - $currentProject = $this->getCurrentProject(true); - if (!$currentProject || $currentProject->id != $event->getProject()->id) { - return; - } - // Ignore the project if it doesn't contain a Drupal application. - if (!Drupal::isDrupal($projectRoot)) { - return; - } - /** @var \Platformsh\Cli\Service\Drush $drush */ - $drush = $this->getService('drush'); - if ($drush->getVersion() === false) { - $this->debug('Not updating Drush aliases: the Drush version cannot be determined.'); - return; - } - $this->debug('Updating Drush aliases'); - try { - $drush->createAliases($event->getProject(), $projectRoot, $event->getEnvironments()); - } catch (\Exception $e) { - $this->stdErr->writeln(sprintf( - "Failed to update Drush aliases:\n%s\n", - preg_replace('/^/m', ' ', trim($e->getMessage())) - )); - } - } - - /** - * @param string $root - */ - protected function setProjectRoot($root) - { - if (!is_dir($root)) { - throw new \InvalidArgumentException("Invalid project root: $root"); - } - self::$projectRoot = $root; - } - - /** - * @return string|false - */ - public function getProjectRoot() - { - if (!isset(self::$projectRoot)) { - $this->debug('Finding the project root'); - /** @var \Platformsh\Cli\Local\LocalProject $localProject */ - $localProject = $this->getService('local.project'); - self::$projectRoot = $localProject->getProjectRoot(); - $this->debug( - self::$projectRoot - ? 'Project root found: ' . self::$projectRoot - : 'Project root not found' - ); - } - - return self::$projectRoot; - } - - /** - * @return bool - */ - protected function selectedProjectIsCurrent() - { - $current = $this->getCurrentProject(true); - if (!$current || !$this->hasSelectedProject()) { - return false; - } - - return $current->id === $this->getSelectedProject()->id; - } - - /** - * Warn the user that the remote environment needs redeploying. - */ - protected function redeployWarning() - { - $this->stdErr->writeln([ - '', - 'The remote environment(s) must be redeployed for the change to take effect.', - 'To redeploy an environment, run: ' . $this->config()->get('application.executable') . ' redeploy', - ]); - } - - /** - * Adds a hidden command option. - * - * @see self::addOption() for the parameters - * - * @return self - */ - protected function addHiddenOption($name, $shortcut = null, $mode = null, $description = '', $default = null) - { - $this->getDefinition()->addOption(new HiddenInputOption($name, $shortcut, $mode, $description, $default)); - - return $this; - } - - /** - * Add the --project and --host options. - * - * @return CommandBase - */ - protected function addProjectOption() - { - $this->addOption('project', 'p', InputOption::VALUE_REQUIRED, 'The project ID or URL'); - $this->addHiddenOption('host', null, InputOption::VALUE_REQUIRED, 'Deprecated option, no longer used'); - - return $this; - } - - /** - * Add the --environment option. - * - * @return CommandBase - */ - protected function addEnvironmentOption() - { - return $this->addOption('environment', 'e', InputOption::VALUE_REQUIRED, 'The environment ID. Use "' . self::DEFAULT_ENVIRONMENT_CODE . '" to select the project\'s default environment.'); - } - - /** - * Add the --app option. - * - * @return CommandBase - */ - protected function addAppOption() - { - return $this->addOption('app', 'A', InputOption::VALUE_REQUIRED, 'The remote application name'); - } - - /** - * Add both the --no-wait and --wait options. - */ - protected function addWaitOptions() - { - $this->addOption('no-wait', 'W', InputOption::VALUE_NONE, 'Do not wait for the operation to complete'); - if ($this->detectRunningInHook()) { - $this->addOption('wait', null, InputOption::VALUE_NONE, 'Wait for the operation to complete'); - } else { - $this->addOption('wait', null, InputOption::VALUE_NONE, 'Wait for the operation to complete (default)'); - } - } - - /** - * Returns whether we should wait for an operation to complete. - * - * @param \Symfony\Component\Console\Input\InputInterface $input - * - * @return bool - */ - protected function shouldWait(InputInterface $input) - { - if ($input->hasOption('no-wait') && $input->getOption('no-wait')) { - return false; - } - if ($input->hasOption('wait') && $input->getOption('wait')) { - return true; - } - if ($this->detectRunningInHook()) { - $serviceName = $this->config()->get('service.name'); - $message = "\nWarning: $serviceName hook environment detected: assuming --no-wait by default." - . "\nTo avoid ambiguity, please specify either --no-wait or --wait." - . "\n"; - $this->stdErr->writeln($message); - - return false; - } - - return true; - } - - /** - * Detects a Platform.sh non-terminal Dash environment; i.e. a hook. - * - * @return bool - */ - protected function detectRunningInHook() - { - $envPrefix = $this->config()->get('service.env_prefix'); - if (getenv($envPrefix . 'PROJECT') - && basename(getenv('SHELL')) === 'dash' - && !$this->isTerminal(STDIN)) { - return true; - } - - return false; - } - - /** - * Detects if running within a CI or local container system. - * - * @return bool - */ - private function isCI() - { - return getenv('CI') !== false // GitHub Actions, Travis CI, CircleCI, Cirrus CI, GitLab CI, AppVeyor, CodeShip, dsari - || getenv('BUILD_NUMBER') !== false // Jenkins, TeamCity - || getenv('RUN_ID') !== false // TaskCluster, dsari - || getenv('LANDO_INFO') !== false // Lando (https://docs.lando.dev/guides/lando-info.html) - || getenv('IS_DDEV_PROJECT') === 'true' // DDEV (https://ddev.readthedocs.io/en/latest/users/extend/custom-commands/#environment-variables-provided) - || $this->detectRunningInHook(); // PSH - } - - /** - * Detects if the CLI is running wrapped inside the go wrapper. - * - * @return bool - */ - protected function isWrapped() - { - return $this->config()->isWrapped(); - } - - /** - * Select the project for the user, based on input or the environment. - * - * @param string $projectId - * @param string $host - * @param bool $detectCurrent - * - * @return Project - */ - protected function selectProject($projectId = null, $host = null, $detectCurrent = true) - { - if (!empty($projectId)) { - $this->project = $this->api()->getProject($projectId, $host); - if (!$this->project) { - throw new ConsoleInvalidArgumentException($this->getProjectNotFoundMessage($projectId)); - } - - return $this->project; - } - - $this->project = $detectCurrent ? $this->getCurrentProject() : false; - if (!$this->project && isset($this->input) && $this->input->isInteractive()) { - $myProjects = $this->api()->getMyProjects(); - if (count($myProjects) > 0) { - $this->debug('No project specified: offering a choice...'); - $projectId = $this->offerProjectChoice($myProjects); - - return $this->selectProject($projectId); - } - } - if (!$this->project) { - if ($detectCurrent) { - throw new RootNotFoundException( - "Could not determine the current project." - . "\n\nSpecify it using --project, or go to a project directory." - ); - } else { - throw new ConsoleInvalidArgumentException('You must specify a project.'); - } - } - - return $this->project; - } - - /** - * Format an error message about a not-found project. - * - * @param string $projectId - * - * @return string - */ - private function getProjectNotFoundMessage($projectId) - { - $message = 'Specified project not found: ' . $projectId; - if ($projectInfos = $this->api()->getMyProjects()) { - $message .= "\n\nYour projects are:"; - $limit = 8; - foreach (array_slice($projectInfos, 0, $limit) as $info) { - $message .= "\n " . $info->id; - if ($info->title !== '') { - $message .= ' - ' . $info->title; - } - } - if (count($projectInfos) > $limit) { - $message .= "\n ..."; - $message .= "\n\n List projects with: " . $this->config()->get('application.executable') . ' projects'; - } - } - - return $message; - } - - /** - * Returns an environment filter to select environments by status. - * - * @param string[] $statuses - * - * @return callable - */ - protected function filterEnvsByStatus(array $statuses) - { - return function (Environment $e) use ($statuses) { - return \in_array($e->status, $statuses, true); - }; - } - - /** - * Filters environments to those that may be active. - * - * @return callable - */ - protected function filterEnvsMaybeActive() - { - return function (Environment $e) { - return \in_array($e->status, ['active', 'dirty'], true) || count($e->getSshUrls()) > 0; - }; - } - - /** - * Select the current environment for the user. - * - * @throws \RuntimeException If the current environment cannot be found. - * - * @param string|null $environmentId - * The environment ID specified by the user, or null to auto-detect the - * environment. - * @param bool $required - * Whether it's required to have an environment. - * @param bool $selectDefaultEnv - * Whether to select a default environment. - * @param bool $detectCurrentEnv - * Whether to detect the current environment from Git. - * @param null|callable $filter - * If an interactive choice is given, filter the choice of environments. - * This is a callback accepting an Environment and returning a boolean. - * Defaults to the $chooseEnvFilter property. - */ - protected function selectEnvironment($environmentId = null, $required = true, $selectDefaultEnv = false, $detectCurrentEnv = true, $filter = null) - { - $envPrefix = $this->config()->get('service.env_prefix'); - if ($environmentId === null && getenv($envPrefix . 'BRANCH')) { - $environmentId = getenv($envPrefix . 'BRANCH'); - $this->stdErr->writeln(sprintf( - 'Environment ID read from environment variable %s: %s', - $envPrefix . 'BRANCH', - $environmentId - ), OutputInterface::VERBOSITY_VERBOSE); - } - - if ($environmentId !== null) { - if ($environmentId === self::DEFAULT_ENVIRONMENT_CODE) { - $this->stdErr->writeln(sprintf('Selecting default environment (indicated by %s)', $environmentId)); - $environments = $this->api()->getEnvironments($this->project); - $environment = $this->api()->getDefaultEnvironment($environments, $this->project, true); - if (!$environment) { - throw new \RuntimeException('Default environment not found'); - } - $this->stdErr->writeln(\sprintf('Selected environment: %s', $this->api()->getEnvironmentLabel($environment))); - $this->printedSelectedEnvironment = true; - $this->environment = $environment; - return; - } - - $environment = $this->api()->getEnvironment($environmentId, $this->project, null, true); - if (!$environment) { - throw new ConsoleInvalidArgumentException('Specified environment not found: ' . $environmentId); - } - - $this->environment = $environment; - return; - } - - if ($detectCurrentEnv && ($environment = $this->getCurrentEnvironment($this->project ?: null))) { - $this->environment = $environment; - return; - } - - if ($selectDefaultEnv) { - $this->debug('No environment specified or detected: finding a default...'); - $environments = $this->api()->getEnvironments($this->project); - $environment = $this->api()->getDefaultEnvironment($environments, $this->project); - if ($environment) { - $this->stdErr->writeln(\sprintf('Selected default environment: %s', $this->api()->getEnvironmentLabel($environment))); - $this->printedSelectedEnvironment = true; - $this->environment = $environment; - return; - } - } - - if ($required && isset($this->input) && $this->input->isInteractive()) { - $environments = $this->api()->getEnvironments($this->project); - if ($filter === null && $this->chooseEnvFilter !== null) { - $filter = $this->chooseEnvFilter; - } - if ($filter !== null) { - $environments = array_filter($environments, $filter); - } - if (count($environments) === 1) { - $only = reset($environments); - $this->stdErr->writeln(\sprintf('Selected environment: %s (by default)', $this->api()->getEnvironmentLabel($only))); - $this->printedSelectedEnvironment = true; - $this->environment = $only; - return; - } - if (count($environments) > 0) { - $this->debug('No environment specified or detected: offering a choice...'); - $this->environment = $this->offerEnvironmentChoice($environments); - return; - } - throw new ConsoleInvalidArgumentException( 'Could not select an environment automatically.' - . "\n" . 'Specify one manually using --environment (-e).'); - } - - if ($required && !$this->environment) { - if ($this->getProjectRoot() || !$detectCurrentEnv) { - $message = 'Could not determine the current environment.' - . "\n" . 'Specify it manually using --environment (-e).'; - } else { - $message = 'No environment specified.' - . "\n" . 'Specify one using --environment (-e), or go to a project directory.'; - } - throw new ConsoleInvalidArgumentException($message); - } - } - - /** - * Add the --app and --worker options. - */ - protected function addRemoteContainerOptions() - { - if (!$this->getDefinition()->hasOption('app')) { - $this->addAppOption(); - } - if (!$this->getDefinition()->hasOption('worker')) { - $this->addOption('worker', null, InputOption::VALUE_REQUIRED, 'A worker name'); - } - $this->addOption('instance', 'I', InputOption::VALUE_REQUIRED, 'An instance ID'); - return $this; - } - - /** - * Find what app or worker container the user wants to select. - * - * Needs the --app and --worker options, as applicable. - * - * @param InputInterface $input - * The user input object. - * @param bool $includeWorkers - * Whether to include workers in the selection. - * - * @return \Platformsh\Cli\Model\RemoteContainer\RemoteContainerInterface - * A class representing a container that allows SSH access. - */ - protected function selectRemoteContainer(InputInterface $input, $includeWorkers = true) - { - if (isset($this->remoteContainer)) { - return $this->remoteContainer; - } - - $environment = $this->getSelectedEnvironment(); - - try { - $deployment = $this->api()->getCurrentDeployment( - $environment, - $input->hasOption('refresh') && $input->getOption('refresh') - ); - } catch (EnvironmentStateException $e) { - if ($environment->isActive() && $e->getMessage() === 'Current deployment not found') { - $appName = $input->hasOption('app') ? $input->getOption('app') : ''; - - return $this->remoteContainer = new RemoteContainer\BrokenEnv($environment, $appName); - } - throw $e; - } - - // Read the --app and --worker options, if the latter is specified then it will be used. - $appOption = $input->hasOption('app') ? $input->getOption('app') : null; - $workerOption = $includeWorkers && $input->hasOption('worker') ? $input->getOption('worker') : null; - - if ($appOption !== null) { - try { - $webApp = $deployment->getWebApp($appOption); - } catch (\InvalidArgumentException $e) { - throw new ConsoleInvalidArgumentException('Application not found: ' . $appOption); - } - - // No worker option specified so select app directly. - if ($workerOption === null) { - $this->stdErr->writeln(sprintf('Selected app: %s', $webApp->name), OutputInterface::VERBOSITY_VERBOSE); - - return $this->remoteContainer = new RemoteContainer\App($webApp, $environment); - } - - unset($webApp); // object is no longer required. - } - - if ($workerOption !== null) { - // Check for a conflict with the --app option. - if ($appOption !== null - && strpos($workerOption, '--') !== false - && stripos($workerOption, $appOption . '--') !== 0) { - throw new ConsoleInvalidArgumentException(sprintf( - 'App name "%s" conflicts with worker name "%s"', - $appOption, - $workerOption - )); - } - - // If we have the app name, load the worker directly. - if (strpos($workerOption, '--') !== false || $appOption !== null) { - $qualifiedWorkerName = strpos($workerOption, '--') !== false - ? $workerOption - : $appOption . '--' . $workerOption; - try { - $worker = $deployment->getWorker($qualifiedWorkerName); - } catch (\InvalidArgumentException $e) { - throw new ConsoleInvalidArgumentException('Worker not found: ' . $workerOption . ' (in app: ' . $appOption . ')'); - } - $this->stdErr->writeln(sprintf('Selected worker: %s', $worker->name), OutputInterface::VERBOSITY_VERBOSE); - - return $this->remoteContainer = new RemoteContainer\Worker($worker, $environment); - } - - // If we don't have the app name, find all the possible matching - // workers, and ask the user to choose. - $suffix = '--' . $workerOption; - $suffixLength = strlen($suffix); - $workerNames = []; - foreach ($deployment->workers as $worker) { - if (substr($worker->name, -$suffixLength) === $suffix) { - $workerNames[] = $worker->name; - } - } - if (count($workerNames) === 0) { - throw new ConsoleInvalidArgumentException('Worker not found: ' . $workerOption); - } - if (count($workerNames) === 1) { - $workerName = reset($workerNames); - $this->stdErr->writeln(sprintf('Selected worker: %s', $workerName), OutputInterface::VERBOSITY_VERBOSE); - - return $this->remoteContainer = new RemoteContainer\Worker($deployment->getWorker($workerName), $environment); - } - if (!$input->isInteractive()) { - throw new ConsoleInvalidArgumentException(sprintf( - 'Ambiguous worker name: %s (matches: %s)', - $workerOption, - implode(', ', $workerNames) - )); - } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $workerName = $questionHelper->choose( - $workerNames, - 'Enter a number to choose a worker:' - ); - $this->stdErr->writeln(sprintf('Selected worker: %s', $workerName), OutputInterface::VERBOSITY_VERBOSE); - - return $this->remoteContainer = new RemoteContainer\Worker($deployment->getWorker($workerName), $environment); - } - - // Prompt the user to choose between the app(s) or worker(s) that have - // been found. - $appNames = $appOption !== null - ? [$appOption] - : array_map(function (WebApp $app) { return $app->name; }, $deployment->webapps); - $choices = array_combine($appNames, $appNames); - $choicesIncludeWorkers = false; - if ($includeWorkers) { - $servicesWithSsh = []; - foreach ($environment->getSshUrls() as $key => $sshUrl) { - $parts = explode(':', $key, 2); - $servicesWithSsh[$parts[0]] = $sshUrl; - } - foreach ($deployment->workers as $worker) { - if (!isset($servicesWithSsh[$worker->name])) { - // Only include workers in the interactive selection if they - // have SSH endpoints. Some Dedicated environments do not have - // separate SSH endpoints for workers. - continue; - } - list($appPart, ) = explode('--', $worker->name, 2); - if (in_array($appPart, $appNames, true)) { - $choices[$worker->name] = $worker->name; - $choicesIncludeWorkers = true; - } - } - } - if (count($choices) === 0) { - throw new \RuntimeException('Failed to find apps or workers for environment: ' . $environment->id); - } - if (count($appNames) === 1) { - $choice = reset($appNames); - } elseif ($input->isInteractive()) { - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - if ($choicesIncludeWorkers) { - $text = sprintf('Enter a number to choose an app or %s worker:', - count($choices) === 2 ? 'its' : 'a' - ); - } else { - $text = 'Enter a number to choose an app:'; - } - ksort($choices, SORT_NATURAL); - $choice = $questionHelper->choose($choices, $text); - } else { - throw new ConsoleInvalidArgumentException( - $includeWorkers - ? 'Specifying the --app or --worker is required in non-interactive mode' - : 'Specifying the --app is required in non-interactive mode' - ); - } - - // Match the choice to a worker or app destination. - if (strpos($choice, '--') !== false) { - $this->stdErr->writeln(sprintf('Selected worker: %s', $choice), OutputInterface::VERBOSITY_VERBOSE); - return $this->remoteContainer = new RemoteContainer\Worker($deployment->getWorker($choice), $environment); - } - - $this->stdErr->writeln(sprintf('Selected app: %s', $choice), OutputInterface::VERBOSITY_VERBOSE); - - return $this->remoteContainer = new RemoteContainer\App($deployment->getWebApp($choice), $environment); - } - - /** - * Find the name of the app the user wants to use. - * - * @param InputInterface $input - * The user input object. - * - * @return string|null - * The application name, or null if it could not be found. - */ - protected function selectApp(InputInterface $input) - { - $appName = $input->getOption('app'); - if ($appName) { - return $appName; - } - - return $this->selectRemoteContainer($input, false)->getName(); - } - - /** - * Offer the user an interactive choice of projects. - * - * @param BasicProjectInfo[] $projectInfos - * - * @return string - * The chosen project ID. - */ - private function offerProjectChoice(array $projectInfos) - { - if (!isset($this->input) || !isset($this->output) || !$this->input->isInteractive()) { - throw new \BadMethodCallException('Not interactive: a project choice cannot be offered.'); - } +declare(strict_types=1); - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); +namespace Platformsh\Cli\Command; - if (count($projectInfos) >= 25 || count($projectInfos) > (new Terminal())->getHeight() - 3) { - $autocomplete = []; - foreach ($projectInfos as $info) { - if ($info->title) { - $autocomplete[$info->id] = $info->id . ' - ' . $info->title . ''; - } else { - $autocomplete[$info->id] = $info->id; - } - } - asort($autocomplete, SORT_NATURAL | SORT_FLAG_CASE); - return $questionHelper->askInput($this->enterProjectText, null, array_values($autocomplete), function ($value) use ($autocomplete) { - list($id, ) = explode(' - ', $value); - if (empty(trim($id))) { - throw new ConsoleInvalidArgumentException('A project ID is required'); - } - if (!isset($autocomplete[$id]) && !$this->api()->getProject($id)) { - throw new ConsoleInvalidArgumentException('Project not found: ' . $id); - } - return $id; - }); - } +use Platformsh\Cli\Console\CompleterInterface; +use Platformsh\Cli\Console\HiddenInputOption; +use Platformsh\Cli\Service\Config; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\ConsoleOutputInterface; +use Symfony\Component\Console\Output\NullOutput; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Contracts\Service\Attribute\Required; - $projectList = []; - foreach ($projectInfos as $info) { - $projectList[$info->id] = $this->api()->getProjectLabel($info, false); - } - asort($projectList, SORT_NATURAL | SORT_FLAG_CASE); +abstract class CommandBase extends Command implements MultiAwareInterface +{ + public const STABILITY_STABLE = 'STABLE'; + public const STABILITY_BETA = 'BETA'; + public const STABILITY_DEPRECATED = 'DEPRECATED'; - return $questionHelper->choose($projectList, $this->chooseProjectText, null, false); - } + protected OutputInterface $stdErr; /** - * Offers a choice of environments. + * Sets whether the command is hidden in the list. * - * @param Environment[] $environments + * The AsCommand attribute has the 'hidden' property, which allows for + * hiding lazily-loaded commands. However, it prevents the commands being + * accessed via abbreviations. Additionally some commands are dynamically + * hidden (by overriding isHidden()), so they must be fully loaded anyway + * before rendering the list. * - * @return Environment + * @var bool */ - final protected function offerEnvironmentChoice(array $environments) - { - if (!isset($this->input) || !isset($this->output) || !$this->input->isInteractive()) { - throw new \BadMethodCallException('Not interactive: an environment choice cannot be offered.'); - } - - $defaultEnvironment = $this->api()->getDefaultEnvironment($environments, $this->project); - $defaultEnvironmentId = $defaultEnvironment ? $defaultEnvironment->id : null; + protected bool $hiddenInList = false; - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - - if (count($environments) > (new Terminal())->getHeight() / 2) { - $ids = array_keys($environments); - sort($ids, SORT_NATURAL | SORT_FLAG_CASE); - - $id = $questionHelper->askInput($this->enterEnvText, $defaultEnvironmentId, array_keys($environments), function ($value) use ($environments) { - if (!isset($environments[$value])) { - throw new \RuntimeException('Environment not found: ' . $value); - } - - return $value; - }); - } else { - $environmentList = []; - foreach ($environments as $environment) { - $environmentList[$environment->id] = $this->api()->getEnvironmentLabel($environment, false); - } - asort($environmentList, SORT_NATURAL | SORT_FLAG_CASE); - - $text = $this->chooseEnvText; - if ($defaultEnvironmentId) { - $text .= "\n" . 'Default: ' . $defaultEnvironmentId . ''; - } - - $id = $questionHelper->choose($environmentList, $text, $defaultEnvironmentId, false); - } - - return $environments[$id]; - } + protected string $stability = self::STABILITY_STABLE; + protected bool $canBeRunMultipleTimes = true; + protected bool $runningViaMulti = false; /** - * @param InputInterface $input - * @param bool $envNotRequired - * @param bool $selectDefaultEnv - * @param bool $detectCurrent Whether to detect the project/environment from the current working directory. + * @var string[] + * @see self::setHiddenAliases() */ - final protected function validateInput(InputInterface $input, $envNotRequired = false, $selectDefaultEnv = false, $detectCurrent = true) - { - $projectId = $input->hasOption('project') ? $input->getOption('project') : null; - $projectHost = $input->hasOption('host') ? $input->getOption('host') : null; - $environmentId = null; - - // Warn about using the deprecated --host option. - $this->warnAboutDeprecatedOptions(['host']); - - // Identify the project. - if ($projectId !== null) { - /** @var \Platformsh\Cli\Service\Identifier $identifier */ - $identifier = $this->getService('identifier'); - $result = $identifier->identify($projectId); - $projectId = $result['projectId']; - $projectHost = $projectHost ?: $result['host']; - $environmentId = $result['environmentId']; - } - - // Load the project ID from an environment variable, if available. - $envPrefix = $this->config()->get('service.env_prefix'); - if ($projectId === null && getenv($envPrefix . 'PROJECT')) { - $projectId = getenv($envPrefix . 'PROJECT'); - $this->stdErr->writeln(sprintf( - 'Project ID read from environment variable %s: %s', - $envPrefix . 'PROJECT', - $projectId - ), OutputInterface::VERBOSITY_VERBOSE); - } - - // Set the --app option. - if ($input->hasOption('app') && !$input->getOption('app') && !$this->getDefinition()->getOption('app')->isArray()) { - // An app ID might be provided from the parsed project URL. - if (isset($result['appId'])) { - $input->setOption('app', $result['appId']); - $this->debug(sprintf( - 'App name identified as: %s', - $input->getOption('app') - )); - } - // Or from an environment variable. - elseif (getenv($envPrefix . 'APPLICATION_NAME')) { - $input->setOption('app', getenv($envPrefix . 'APPLICATION_NAME')); - $this->stdErr->writeln(sprintf( - 'App name read from environment variable %s: %s', - $envPrefix . 'APPLICATION_NAME', - $input->getOption('app') - ), OutputInterface::VERBOSITY_VERBOSE); - } - } - - // Select the project. - $project = $this->selectProject($projectId, $projectHost, $detectCurrent); - if ($this->stdErr->isVerbose()) { - $this->stdErr->writeln('Selected project: ' . $this->api()->getProjectLabel($project)); - $this->printedSelectedProject = true; - } - - // Select the environment. - $envOptionName = 'environment'; - $this->printedSelectedEnvironment = false; - if ($input->hasArgument($this->envArgName) - && $input->getArgument($this->envArgName) !== null - && $input->getArgument($this->envArgName) !== []) { - if ($input->hasOption($envOptionName) && $input->getOption($envOptionName) !== null) { - throw new ConsoleInvalidArgumentException( - sprintf( - 'You cannot use both the <%s> argument and the --%s option', - $this->envArgName, - $envOptionName - ) - ); - } - $argument = $input->getArgument($this->envArgName); - if (is_array($argument)) { - $argument = ArrayArgument::split($argument); - if (count($argument) === 1) { - $argument = $argument[0]; - } - } - if (!is_array($argument)) { - $this->debug('Selecting environment based on input argument'); - $this->selectEnvironment($argument, true, $selectDefaultEnv, $detectCurrent); - } - } elseif ($input->hasOption($envOptionName)) { - if ($input->getOption($envOptionName) !== null) { - $environmentId = $input->getOption($envOptionName); - } - $this->selectEnvironment($environmentId, !$envNotRequired, $selectDefaultEnv, $detectCurrent); - } - - if ($this->stdErr->isVerbose()) { - $this->ensurePrintSelectedEnvironment(); - } - } + private array $hiddenAliases = []; /** - * Prints the selected project, if it has not already been printed. - * - * @param bool $blankLine Append an extra newline after the message, if any is printed. + * The command synopsis. + * @var array */ - protected function ensurePrintSelectedProject($blankLine = false) { - if (!$this->printedSelectedProject && $this->project) { - $this->stdErr->writeln('Selected project: ' . $this->api()->getProjectLabel($this->project)); - $this->printedSelectedProject = true; - if ($blankLine) { - $this->stdErr->writeln(''); - } - } - } + private array $synopsis = []; - /** - * Prints the selected environment, if it has not already been printed. - * - * Also prints the selected project if necessary. - * - * @param bool $blankLine Append an extra newline after the message, if any is printed. - */ - protected function ensurePrintSelectedEnvironment($blankLine = false) { - if (!$this->printedSelectedEnvironment) { - if (!$this->environment) { - $this->ensurePrintSelectedProject($blankLine); - return; - } - $this->ensurePrintSelectedProject(); - $this->stdErr->writeln('Selected environment: ' . $this->api()->getEnvironmentLabel($this->environment)); - $this->printedSelectedEnvironment = true; - if ($blankLine) { - $this->stdErr->writeln(''); - } - } - } + private ?Config $config = null; - /** - * Check whether a project is selected. - * - * @return bool - */ - protected function hasSelectedProject() + /** @var CompleterInterface[] */ + private array $completers = []; + + /** @var array */ + private array $examples = []; + + public function __construct() { - return !empty($this->project); + $this->stdErr = new NullOutput(); + parent::__construct(); } - /** - * Get the project selected by the user. - * - * The project is selected via validateInput(), if there is a --project - * option in the command. - * - * @throws \BadMethodCallException - * - * @return Project - */ - protected function getSelectedProject() + #[Required] + public function setConfig(Config $config): void { - if (!$this->project) { - throw new \BadMethodCallException('No project selected'); - } - - return $this->project; + $this->config = $config; } /** - * Check whether a single environment is selected. + * {@inheritdoc} * - * @return bool + * This intentionally ignores the 'hidden' argument of Symfony's AsCommand + * attribute, because that is already checked during lazy command loading. */ - protected function hasSelectedEnvironment() + public function isHidden(): bool { - return !empty($this->environment); + return $this->hiddenInList + || !in_array($this->stability, [self::STABILITY_STABLE, self::STABILITY_BETA]) + || $this->config->isCommandHidden($this->getName()); } - /** - * Get the environment selected by the user. - * - * The project is selected via validateInput(), if there is an - * --environment option in the command. - * - * @return Environment - */ - protected function getSelectedEnvironment() + protected function initialize(InputInterface $input, OutputInterface $output): void { - if (!$this->environment) { - throw new \BadMethodCallException('No environment selected'); - } - - return $this->environment; + $this->stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; } - /** - * Run another CLI command. - * - * @param string $name - * The name of the other command. - * @param array $arguments - * Arguments for the other command. - * Unambiguous options that both commands have in common will be passed - * on automatically. - * @param OutputInterface $output - * The output for the other command. Defaults to the current output. - * - * @return int - */ - protected function runOtherCommand($name, array $arguments = [], OutputInterface $output = null) + protected function interact(InputInterface $input, OutputInterface $output): void { - /** @var \Platformsh\Cli\Application $application */ - $application = $this->getApplication(); - /** @var Command $command */ - $command = $application->find($name); - - if (isset($this->input)) { - $this->forwardStandardOptions($arguments, $this->input, $command->getDefinition()); - } - - $cmdInput = new ArrayInput(['command' => $name] + $arguments); - if (!empty($arguments['--yes']) || !empty($arguments['--no'])) { - $cmdInput->setInteractive(false); - } elseif (isset($this->input)) { - $cmdInput->setInteractive($this->input->isInteractive()); - } - - if ($this->stdErr->isVeryVerbose()) { - $this->stdErr->writeln( - '# Running subcommand: ' . $cmdInput->__toString() . '' - ); - } - - // Give the other command an entirely new service container, because the - // "input" and "output" parameters, and all their dependents, need to - // change. - $container = self::$container; - self::$container = null; - $application->setCurrentCommand($command); - - // Use a try/finally pattern to ensure that state is restored, even if - // an exception is thrown in $command->run() and caught by the caller. - try { - $result = $command->run($cmdInput, $output ?: $this->output); - } finally { - $application->setCurrentCommand($this); - // Restore the old service container. - self::$container = $container; + // Work around a bug in Console which means the default command's input + // is always considered to be interactive. + // TODO check if this is still needed + if ($this->getName() === 'welcome' + && isset($GLOBALS['argv']) + && array_intersect($GLOBALS['argv'], ['-n', '--no', '-y', '---yes'])) { + $input->setInteractive(false); } - - return $result; } /** - * Forwards standard (unambiguous) arguments that a source and target command have in common. + * Adds a hidden command option. * - * @param array &$args - * @param InputInterface $input - * @param InputDefinition $targetDef + * @param int-mask-of|null $mode */ - private function forwardStandardOptions(array &$args, InputInterface $input, InputDefinition $targetDef) + protected function addHiddenOption(string $name, string|null $shortcut = null, ?int $mode = null, string $description = '', mixed $default = null): static { - $stdOptions = [ - 'no', - 'no-interaction', - 'yes', - - 'no-wait', - 'wait', + $this->getDefinition()->addOption(new HiddenInputOption($name, $shortcut, $mode, $description, $default)); - 'org', - 'host', - 'project', - 'environment', - 'app', - 'worker', - 'instance', - ]; - foreach ($stdOptions as $name) { - if (!\array_key_exists('--' . $name, $args) && $targetDef->hasOption($name) && $input->hasOption($name)) { - $value = $input->getOption($name); - if ($value !== null && $value !== false) { - $args['--' . $name] = $value; - } - } - } + return $this; } /** @@ -1849,11 +121,11 @@ private function forwardStandardOptions(array &$args, InputInterface $input, Inp * * @see parent::setAliases() * - * @param array $hiddenAliases + * @param string[] $hiddenAliases * - * @return CommandBase + * @return static */ - protected function setHiddenAliases(array $hiddenAliases) + protected function setHiddenAliases(array $hiddenAliases): static { $this->hiddenAliases = $hiddenAliases; $this->setAliases(array_merge($this->getAliases(), $hiddenAliases)); @@ -1864,9 +136,9 @@ protected function setHiddenAliases(array $hiddenAliases) /** * Get aliases that should be visible in help. * - * @return array + * @return string[] */ - public function getVisibleAliases() + public function getVisibleAliases(): array { return array_diff($this->getAliases(), $this->hiddenAliases); } @@ -1877,7 +149,7 @@ public function getVisibleAliases() * Overrides the default method so that the description is not repeated * twice. */ - public function getProcessedHelp() + public function getProcessedHelp(): string { $help = $this->getHelp(); if ($help === '') { @@ -1886,125 +158,20 @@ public function getProcessedHelp() $name = $this->getName(); $placeholders = ['%command.name%', '%command.full_name%']; - $replacements = [$name, $this->config()->get('application.executable') . ' ' . $name]; + $replacements = [$name, $this->config->getStr('application.executable') . ' ' . $name]; return str_replace($placeholders, $replacements, $help); } - /** - * Print a message if debug output is enabled. - * - * @param string $message - */ - protected function debug($message) - { - $this->labeledMessage('DEBUG', $message, OutputInterface::VERBOSITY_DEBUG); - } - - /** - * Print a warning about deprecated option(s). - * - * @param string[] $options A list of option names (without "--"). - * @param string|null $template The warning message template. "%s" is - * replaced by the option name. - */ - protected function warnAboutDeprecatedOptions(array $options, $template = null) - { - if (!isset($this->input)) { - return; - } - if ($template === null) { - $template = 'The option --%s is deprecated and no longer used. It will be removed in a future version.'; - } - foreach ($options as $option) { - if ($this->input->hasOption($option) && $this->input->getOption($option)) { - $this->labeledMessage( - 'DEPRECATED', - sprintf($template, $option) - ); - } - } - } - - /** - * Print a message with a label. - * - * @param string $label - * @param string $message - * @param int $options - */ - private function labeledMessage($label, $message, $options = 0) - { - if (isset($this->stdErr)) { - $this->stdErr->writeln('' . strtoupper($label) . ' ' . $message, $options); - } - } - - /** - * Get a service object. - * - * Services are configured in services.yml, and loaded via the Symfony - * Dependency Injection component. - * - * When using this method, always store the result in a temporary variable, - * so that the service's type can be hinted in a variable docblock (allowing - * IDEs and other analysers to check subsequent code). For example: - * - * /** @var \Platformsh\Cli\Service\Filesystem $fs *\/ - * $fs = $this->getService('fs'); - * - * - * @param string $name The service name. See services.yml for a list. - * - * @return object The associated service object. - */ - protected function getService($name) - { - return $this->container()->get($name); - } - - /** - * @return ContainerBuilder - */ - private function container() - { - if (!isset(self::$container)) { - self::$container = new ContainerBuilder(); - $loader = new YamlFileLoader(self::$container, new FileLocator()); - $loader->load(CLI_ROOT . '/services.yaml'); - } - - return self::$container; - } - - /** - * Get the configuration service. - * - * @return \Platformsh\Cli\Service\Config - */ - protected function config() - { - static $config; - if (!isset($config)) { - /** @var \Platformsh\Cli\Service\Config $config */ - $config = $this->getService('config'); - } - - return $config; - } - /** * {@inheritdoc} */ - public function canBeRunMultipleTimes() + public function canBeRunMultipleTimes(): bool { return $this->canBeRunMultipleTimes; } - /** - * {@inheritdoc} - */ - public function setRunningViaMulti($runningViaMulti = true) + public function setRunningViaMulti(bool $runningViaMulti = true): void { $this->runningViaMulti = $runningViaMulti; } @@ -2012,21 +179,19 @@ public function setRunningViaMulti($runningViaMulti = true) /** * {@inheritdoc} */ - public function getSynopsis($short = false) + public function getSynopsis($short = false): string { $key = $short ? 'short' : 'long'; if (!isset($this->synopsis[$key])) { $definition = clone $this->getDefinition(); - $definition->setOptions(array_filter($definition->getOptions(), function (InputOption $opt) { - return !$opt instanceof HiddenInputOption; - })); + $definition->setOptions(array_filter($definition->getOptions(), fn(InputOption $opt): bool => !$opt instanceof HiddenInputOption)); $this->synopsis[$key] = trim(sprintf( '%s %s %s', - $this->config()->get('application.executable'), + $this->config->getStr('application.executable'), $this->getPreferredName(), - $definition->getSynopsis($short) + $definition->getSynopsis($short), )); } @@ -2038,7 +203,7 @@ public function getSynopsis($short = false) * * @return string */ - public function getPreferredName() + public function getPreferredName(): string { if ($visibleAliases = $this->getVisibleAliases()) { return reset($visibleAliases); @@ -2046,42 +211,16 @@ public function getPreferredName() return $this->getName(); } - /** - * @param resource|int $descriptor - * - * @return bool - */ - protected function isTerminal($descriptor) - { - return !function_exists('posix_isatty') || posix_isatty($descriptor); - } - - /** - * {@inheritdoc} - */ - public function isEnabled() - { - return $this->config()->isCommandEnabled($this->getName()); - } - - /** - * Get help on how to use API tokens non-interactively. - * - * @param string $tag - * - * @return string - */ - protected function getNonInteractiveAuthHelp($tag = 'info') + public function isEnabled(): bool { - $prefix = $this->config()->get('application.env_prefix'); - - return "To authenticate non-interactively, configure an API token using the <$tag>${prefix}TOKEN environment variable."; + return $this->config->isCommandEnabled($this->getName()); } /** * {@inheritDoc} */ - public function getDescription() { + public function getDescription(): string + { $description = parent::getDescription(); if ($this->stability !== self::STABILITY_STABLE) { @@ -2093,394 +232,32 @@ public function getDescription() { return $description; } - /** - * @param InputInterface $input - * @param bool $allowLocal - * @param RemoteContainer\RemoteContainerInterface|null $remoteContainer - * @param bool $includeWorkers - * - * @return HostInterface - */ - public function selectHost(InputInterface $input, $allowLocal = true, RemoteContainer\RemoteContainerInterface $remoteContainer = null, $includeWorkers = true) - { - /** @var Shell $shell */ - $shell = $this->getService('shell'); - - if ($allowLocal && !LocalHost::conflictsWithCommandLineOptions($input, $this->config()->get('service.env_prefix'))) { - $this->debug('Selected host: localhost'); - - return new LocalHost($shell); - } - - if ($remoteContainer === null) { - if (!$this->hasSelectedEnvironment()) { - $this->chooseEnvFilter = $this->filterEnvsMaybeActive(); - $this->validateInput($input); - } - $remoteContainer = $this->selectRemoteContainer($input, $includeWorkers); - } - - $instanceId = $input->hasOption('instance') ? $input->getOption('instance') : null; - if ($input->hasOption('instance') && $instanceId !== null) { - $instances = $this->getSelectedEnvironment()->getSshInstanceURLs($remoteContainer->getName()); - if ((!empty($instances) || $instanceId !== '0') && !isset($instances[$instanceId])) { - throw new ConsoleInvalidArgumentException("Instance not found: $instanceId. Available instances: " . implode(', ', array_keys($instances))); - } - } - - /** @var Ssh $ssh */ - $ssh = $this->getService('ssh'); - /** @var \Platformsh\Cli\Service\SshDiagnostics $sshDiagnostics */ - $sshDiagnostics = $this->getService('ssh_diagnostics'); - - $sshUrl = $remoteContainer->getSshUrl($instanceId); - $this->debug('Selected host: ' . $sshUrl); - return new RemoteHost($sshUrl, $this->getSelectedEnvironment(), $ssh, $shell, $sshDiagnostics); - } - - /** - * Finalizes login: refreshes SSH certificate, prints account information. - */ - protected function finalizeLogin() - { - // Reset the API client so that it will use the new tokens. - $this->api()->getClient(false, true); - $this->stdErr->writeln('You are logged in.'); - - /** @var \Platformsh\Cli\Service\SshConfig $sshConfig */ - $sshConfig = $this->getService('ssh_config'); - - // Configure SSH host keys. - $sshConfig->configureHostKeys(); - - // Generate a new certificate from the certifier API. - /** @var \Platformsh\Cli\SshCert\Certifier $certifier */ - $certifier = $this->getService('certifier'); - if ($certifier->isAutoLoadEnabled() && $sshConfig->checkRequiredVersion()) { - $this->stdErr->writeln(''); - $this->stdErr->writeln('Generating SSH certificate...'); - try { - $certifier->generateCertificate(null); - $this->stdErr->writeln('A new SSH certificate has been generated.'); - $this->stdErr->writeln('It will be automatically refreshed when necessary.'); - } catch (\Exception $e) { - $this->stdErr->writeln('Failed to generate SSH certificate: ' . $e->getMessage() . ''); - } - } - - // Write session-based SSH configuration. - if ($sshConfig->configureSessionSsh()) { - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $sshConfig->addUserSshConfig($questionHelper); - } - - // Show user account info. - $account = $this->api()->getMyAccount(true); - $this->stdErr->writeln(sprintf( - "\nUsername: %s\nEmail address: %s", - $account['username'], - $account['email'] - )); - } - - /** - * Shows information about the currently logged in user and their session, if applicable. - * - * @param bool $logout Whether this should avoid re-authentication (if an API token is set). - * @param bool $newline Whether to prepend a newline if there is output. - */ - protected function showSessionInfo($logout = false, $newline = true) - { - $api = $this->api(); - $config = $this->config(); - $sessionId = $config->getSessionId(); - if ($sessionId !== 'default' || count($api->listSessionIds()) > 1) { - if ($newline) { - $this->stdErr->writeln(''); - $newline = false; - } - $this->stdErr->writeln(sprintf('The current session ID is: %s', $sessionId)); - if (!$config->isSessionIdFromEnv()) { - $this->stdErr->writeln(sprintf('Change this using: %s session:switch', $config->get('application.executable'))); - } - } - if (!$logout && $api->isLoggedIn()) { - if ($newline) { - $this->stdErr->writeln(''); - } - $account = $api->getMyAccount(); - $this->stdErr->writeln(\sprintf( - 'You are logged in as %s (%s)', - $account['username'], - $account['email'] - )); - } - } - - /** - * Adds the --org (-o) organization name option. - * - * @param bool $includeProjectOption - * Adds a --project option which means the organization may be - * auto-selected based on the current or specified project. - * - * @return self - */ - protected function addOrganizationOptions($includeProjectOption = false) + protected function addCompleter(CompleterInterface $completer): self { - if ($this->config()->getWithDefault('api.organizations', false)) { - $this->addOption('org', 'o', InputOption::VALUE_REQUIRED, 'The organization name (or ID)'); - if ($includeProjectOption && !$this->getDefinition()->hasOption('project')) { - $this->addOption('project', 'p', InputOption::VALUE_REQUIRED, 'The project ID or URL, which auto-selects the organization if --org is not used'); - } - } + $this->completers[] = $completer; return $this; } - /** - * Returns the selected organization according to the --org option. - * - * @param InputInterface $input - * @param string $filterByLink - * If no organization is specified, this filters the list of the organizations presented by the name of a HAL - * link. For example, 'create-subscription' will list organizations under which the user has the permission to - * create a subscription. - * @param string $filterByCapability - * If no organization is specified, this filters the list of the organizations presented to those with the given - * capability. - * @param bool $skipCache - * - * @return Organization - * @throws NoOrganizationsException if the user does not have any organizations matching the filter - * - * @throws \InvalidArgumentException if no organization is specified - * @see CommandBase::addOrganizationOptions() - * - */ - protected function validateOrganizationInput(InputInterface $input, $filterByLink = '', $filterByCapability = '', $skipCache = false) + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void { - if (!$this->config()->getWithDefault('api.organizations', false)) { - throw new \BadMethodCallException('Organizations are not enabled'); - } - - if (($input->hasOption('project') && $input->getOption('project')) || $this->getCurrentProject(true)) { - $this->validateInput($input); - } - - if ($identifier = $input->getOption('org')) { - // Organization names have to be lower case, while organization IDs are the uppercase ULID format. - // So it's easy to distinguish one from the other. - /** @link https://github.com/ulid/spec */ - if (\preg_match('#^[0-9A-HJKMNP-TV-Z]{26}$#', $identifier) === 1) { - $this->debug('Detected organization ID format (ULID): ' . $identifier); - $organization = $this->api()->getOrganizationById($identifier, $skipCache); - } else { - $organization = $this->api()->getOrganizationByName($identifier, $skipCache); - } - if (!$organization) { - throw new ConsoleInvalidArgumentException('Organization not found: ' . $identifier); - } - - // Check for a conflict between the --org and the --project options. - if (($input->hasOption('project') && $input->getOption('project')) - && $this->hasSelectedProject() && ($project = $this->getSelectedProject()) - && $project->getProperty('organization', true, false) !== $organization->id) { - throw new ConsoleInvalidArgumentException("The project $project->id is not part of the organization $organization->id"); - } - - return $organization; - } - - if ($this->hasSelectedProject()) { - $project = $this->getSelectedProject(); - $this->ensurePrintSelectedProject(); - $organization = $this->api()->getOrganizationById($project->getProperty('organization'), $skipCache); - if ($organization) { - $this->stdErr->writeln(\sprintf('Project organization: %s', $this->api()->getOrganizationLabel($organization))); - return $organization; - } - } elseif (($currentProject = $this->getCurrentProject(true)) && $currentProject->hasProperty('organization')) { - $organizationId = $currentProject->getProperty('organization'); - try { - $organization = $this->api()->getOrganizationById($organizationId, $skipCache); - } catch (BadResponseException $e) { - $this->debug('Error when fetching project organization: ' . $e->getMessage()); - $organization = false; - } - if ($organization) { - if ($filterByLink === '' || $organization->hasLink($filterByLink)) { - if ($this->stdErr->isVerbose()) { - $this->ensurePrintSelectedProject(); - $this->stdErr->writeln(\sprintf('Project organization: %s', $this->api()->getOrganizationLabel($organization))); - } - return $organization; - } elseif ($this->stdErr->isVerbose()) { - $this->stdErr->writeln(sprintf( - 'Not auto-selecting project organization %s (it does not have the link %s)', - $this->api()->getOrganizationLabel($organization, 'comment'), - $filterByLink - )); - } - } - } - - $userId = $this->api()->getMyUserId(); - $organizations = $this->api()->getClient()->listOrganizationsWithMember($userId); - - if (!$input->isInteractive()) { - throw new ConsoleInvalidArgumentException('An organization name or ID (--org) is required.'); - } - if (!$organizations) { - throw new NoOrganizationsException('No organizations found.', 0); - } - - $this->api()->sortResources($organizations, 'name'); - $options = []; - $byId = []; - $owned = []; - foreach ($organizations as $organization) { - if ($filterByLink !== '' && !$organization->hasLink($filterByLink)) { - continue; - } - if ($filterByCapability !== '' && !in_array($filterByCapability, $organization->capabilities, true)) { - continue; - } - $options[$organization->id] = $this->api()->getOrganizationLabel($organization, false); - $byId[$organization->id] = $organization; - if ($organization->owner_id === $userId) { - $owned[$organization->id] = $organization; - } - } - if (empty($options)) { - $message = 'No organizations found.'; - $filters = []; - if ($filterByLink !== '') { - $filters[] = sprintf('access to the link "%s"', $filterByLink); - } - if ($filterByCapability !== '') { - $filters[] = sprintf('capability "%s"', $filterByCapability); - } - if ($filters) { - $message = sprintf('No organizations found (filtered by %s).', implode(' and ', $filters)); - } - throw new NoOrganizationsException($message, count($organizations)); - } - if (count($byId) === 1) { - /** @var Organization $organization */ - $organization = reset($byId); - $this->stdErr->writeln(\sprintf('Selected organization: %s (by default)', $this->api()->getOrganizationLabel($organization))); - return $organization; - } - $default = null; - if (count($owned) === 1) { - $default = key($owned); - - // Move the default to the top of the list and label it. - $options = [$default => $options[$default] . ' (default)'] + $options; + parent::complete($input, $suggestions); + foreach ($this->completers as $completer) { + $completer->complete($input, $suggestions); } - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $id = $questionHelper->choose($options, 'Enter a number to choose an organization (-o):', $default); - return $byId[$id]; } - /** - * Adds a --resources-init option to commands that support it. - * - * The option will only be added if the api.sizing feature is enabled. - * - * @param string[] $values - * The possible values, with the default as the first element. - * - * @return self - * - * @see CommandBase::validateResourcesInitInput() - */ - protected function addResourcesInitOption($values, $description = '') + protected function addExample(string $description, string $commandline = ''): self { - if (!$this->config()->get('api.sizing')) { - return $this; - } - $this->validResourcesInitValues = $values; - if ($description === '') { - $description = 'Set the resources to use for new services'; - $description .= ': ' . StringUtil::formatItemList($values); - $default = array_shift($values); - $description .= ".\n" . sprintf('If not set, "%s" will be used.', $default); - } - $this->addOption('resources-init', null, InputOption::VALUE_REQUIRED, $description); + $this->examples[] = ['commandline' => $commandline, 'description' => $description]; return $this; } /** - * Validates and returns the --resources-init input, if any. - * - * @param InputInterface $input - * @param Project $project - * - * @return string|false|null - * The input value, or false if there was a validation error, or null if - * nothing was specified or the input option didn't exist. - * - * @see CommandBase::addResourcesInitOption() - */ - protected function validateResourcesInitInput(InputInterface $input, Project $project) - { - $resourcesInit = $input->hasOption('resources-init') ? $input->getOption('resources-init') : null; - if ($resourcesInit !== null) { - if (!\in_array($resourcesInit, $this->validResourcesInitValues, true)) { - $this->stdErr->writeln('The value for --resources-init must be one of: ' . \implode(', ', $this->validResourcesInitValues)); - return false; - } - if (!$this->api()->supportsSizingApi($project)) { - $this->stdErr->writeln('The --resources-init option cannot be used as the project does not support flexible resources.'); - return false; - } - } - return $resourcesInit; - } - - /** - * Warn the user if a project is suspended. - * - * @param \Platformsh\Client\Model\Project $project - */ - protected function warnIfSuspended(Project $project) - { - if ($project->isSuspended()) { - $this->stdErr->writeln('This project is suspended.'); - if ($this->config()->getWithDefault('warnings.project_suspended_payment', true)) { - $orgId = $project->getProperty('organization', false); - if ($orgId) { - try { - $organization = $this->api()->getClient()->getOrganizationById($orgId); - } catch (BadResponseException $e) { - $organization = false; - } - if ($organization && $organization->hasLink('payment-source')) { - $this->stdErr->writeln(sprintf('To re-activate it, update the payment details for your organization, %s.', $this->api()->getOrganizationLabel($organization, 'comment'))); - } - } elseif ($project->owner === $this->api()->getMyUserId()) { - $this->stdErr->writeln('To re-activate it, update your payment details.'); - } - } - } - } - - /** - * Tests if a project's Git host is external (e.g. Bitbucket, GitHub, GitLab, etc.). - * - * @param Project $project - * @return bool + * @return array */ - protected function hasExternalGitHost(Project $project) + public function getExamples(): array { - /** @var \Platformsh\Cli\Service\Ssh $ssh */ - $ssh = $this->getService('ssh'); - - return $ssh->hostIsInternal($project->getGitUrl()) === false; + return $this->examples; } } diff --git a/src/Command/Commit/CommitGetCommand.php b/src/Command/Commit/CommitGetCommand.php index 2f8ca2b643..bafac1e735 100644 --- a/src/Command/Commit/CommitGetCommand.php +++ b/src/Command/Commit/CommitGetCommand.php @@ -1,30 +1,37 @@ setName('commit:get') - ->setDescription('Show commit details') ->addArgument('commit', InputArgument::OPTIONAL, 'The commit SHA. ' . GitDataApi::COMMIT_SYNTAX_HELP, 'HEAD') ->addOption('property', 'P', InputOption::VALUE_REQUIRED, 'The commit property to display.'); - $this->addProjectOption(); - $this->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); $definition = $this->getDefinition(); PropertyFormatter::configureInput($definition); @@ -43,15 +50,13 @@ protected function configure() /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->warnAboutDeprecatedOptions(['columns', 'format', 'no-header']); - $this->validateInput($input, false, true); + $this->io->warnAboutDeprecatedOptions(['columns', 'format', 'no-header']); + $selection = $this->selector->getSelection($input, new SelectorConfig(selectDefaultEnv: true)); $commitSha = $input->getArgument('commit'); - /** @var \Platformsh\Cli\Service\GitDataApi $gitData */ - $gitData = $this->getService('git_data_api'); - $commit = $gitData->getCommit($this->getSelectedEnvironment(), $commitSha); + $commit = $this->gitDataApi->getCommit($selection->getEnvironment(), $commitSha); if (!$commit) { if ($commitSha) { $this->stdErr->writeln('Commit not found: ' . $commitSha . ''); @@ -61,10 +66,7 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - - /** @var PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - $formatter->displayData($output, $commit->getProperties(), $input->getOption('property')); + $this->propertyFormatter->displayData($output, $commit->getProperties(), $input->getOption('property')); return 0; } diff --git a/src/Command/Commit/CommitListCommand.php b/src/Command/Commit/CommitListCommand.php index 9fae9157ce..c9fe65f475 100644 --- a/src/Command/Commit/CommitListCommand.php +++ b/src/Command/Commit/CommitListCommand.php @@ -1,7 +1,12 @@ */ + private array $tableHeader = ['Date', 'SHA', 'Author', 'Summary']; - private $tableHeader = ['Date', 'SHA', 'Author', 'Summary']; + public function __construct(private readonly Api $api, private readonly GitDataApi $gitDataApi, private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table) + { + parent::__construct(); + } - /** - * {@inheritdoc} - */ - protected function configure() + protected function configure(): void { $this - ->setName('commit:list') - ->setAliases(['commits']) - ->setDescription('List commits') ->addArgument('commit', InputOption::VALUE_REQUIRED, 'The starting Git commit SHA. ' . GitDataApi::COMMIT_SYNTAX_HELP) ->addOption('limit', null, InputOption::VALUE_REQUIRED, 'The number of commits to display.', 10); - $this->addProjectOption(); - $this->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); $definition = $this->getDefinition(); Table::configureInput($definition, $this->tableHeader); @@ -45,15 +52,13 @@ protected function configure() /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input, false, true); - $environment = $this->getSelectedEnvironment(); + $selection = $this->selector->getSelection($input, new SelectorConfig(selectDefaultEnv: true)); + $environment = $selection->getEnvironment(); $startSha = $input->getArgument('commit'); - /** @var \Platformsh\Cli\Service\GitDataApi $gitData */ - $gitData = $this->getService('git_data_api'); - $startCommit = $gitData->getCommit($environment, $startSha); + $startCommit = $this->gitDataApi->getCommit($environment, $startSha); if (!$startCommit) { if ($startSha) { $this->stdErr->writeln('Commit not found: ' . $startSha . ''); @@ -64,28 +69,22 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - /** @var Table $table */ - $table = $this->getService('table'); - - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->stdErr->writeln(sprintf( 'Commits on the project %s, environment %s:', - $this->api()->getProjectLabel($this->getSelectedProject()), - $this->api()->getEnvironmentLabel($environment) + $this->api->getProjectLabel($selection->getProject()), + $this->api->getEnvironmentLabel($environment), )); } $commits = $this->loadCommitList($environment, $startCommit, $input->getOption('limit')); - /** @var PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - $rows = []; foreach ($commits as $commit) { $row = []; $row[] = new AdaptiveTableCell( - $formatter->format($commit->author['date'], 'author.date'), - ['wrap' => false] + $this->propertyFormatter->format($commit->author['date'], 'author.date'), + ['wrap' => false], ); $row[] = $commit->sha; $row[] = $commit->author['name']; @@ -93,7 +92,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $rows[] = $row; } - $table->render($rows, $this->tableHeader); + $this->table->render($rows, $this->tableHeader); return 0; } @@ -101,31 +100,32 @@ protected function execute(InputInterface $input, OutputInterface $output) /** * Load parent commits, recursively, up to the limit. * - * @param \Platformsh\Client\Model\Environment $environment - * @param \Platformsh\Client\Model\Git\Commit $startCommit - * @param int $limit + * @param Environment $environment + * @param Commit $startCommit + * @param int $limit * - * @return \Platformsh\Client\Model\Git\Commit[] + * @return Commit[] */ - private function loadCommitList(Environment $environment, Commit $startCommit, $limit = 10) + private function loadCommitList(Environment $environment, Commit $startCommit, int $limit = 10): array { $commits = [$startCommit]; if (!count($startCommit->parents) || $limit === 1) { return $commits; } - /** @var \Platformsh\Cli\Service\GitDataApi $gitData */ - $gitData = $this->getService('git_data_api'); - $progress = new ProgressBar($this->stdErr->isDecorated() ? $this->stdErr : new NullOutput()); $progress->setMessage('Loading...'); $progress->setFormat('%message% %current% (limit: %max%)'); $progress->start($limit); for ($currentCommit = $startCommit; - count($currentCommit->parents) && count($commits) < $limit;) { + count($currentCommit->parents) && count($commits) < $limit;) { foreach (array_reverse($currentCommit->parents) as $parentSha) { if (!isset($commits[$parentSha])) { - $commits[$parentSha] = $gitData->getCommit($environment, $parentSha); + $commit = $this->gitDataApi->getCommit($environment, $parentSha); + if (!$commit) { + throw new \RuntimeException(sprintf('Commit not found: %s', $parentSha)); + } + $commits[$parentSha] = $commit; } $currentCommit = $commits[$parentSha]; $progress->advance(); @@ -140,13 +140,9 @@ private function loadCommitList(Environment $environment, Commit $startCommit, $ } /** - * Summarize a commit message. - * - * @param string $message - * - * @return string + * Summarizes a commit message. */ - private function summarize($message) + private function summarize(string $message): string { $message = ltrim($message, "\n"); if ($newLinePos = strpos($message, "\n")) { diff --git a/src/Command/CompletionCommand.php b/src/Command/CompletionCommand.php deleted file mode 100644 index 838be4937c..0000000000 --- a/src/Command/CompletionCommand.php +++ /dev/null @@ -1,294 +0,0 @@ -api = new Api(); - $projectInfos = $this->api->isLoggedIn() ? $this->api->getMyProjects(false) : []; - $projectIds = array_map(function (BasicProjectInfo $p) { return $p->id; }, $projectInfos); - - $this->handler->addHandlers([ - new Completion( - Completion::ALL_COMMANDS, - 'project', - Completion::TYPE_OPTION, - $projectIds - ), - new Completion( - Completion::ALL_COMMANDS, - 'project', - Completion::TYPE_ARGUMENT, - $projectIds - ), - new Completion( - Completion::ALL_COMMANDS, - 'environment', - Completion::TYPE_ARGUMENT, - [$this, 'getEnvironments'] - ), - new Completion( - Completion::ALL_COMMANDS, - 'environment', - Completion::TYPE_OPTION, - [$this, 'getEnvironments'] - ), - new Completion( - 'environment:branch', - 'parent', - Completion::TYPE_ARGUMENT, - [$this, 'getEnvironments'] - ), - new Completion( - 'environment:checkout', - 'id', - Completion::TYPE_ARGUMENT, - [$this, 'getEnvironmentsForCheckout'] - ), - new Completion( - 'user:role', - 'level', - Completion::TYPE_OPTION, - ['project', 'environment'] - ), - new Completion\ShellPathCompletion( - 'ssh-key:add', - 'path', - Completion::TYPE_ARGUMENT - ), - new Completion\ShellPathCompletion( - 'domain:add', - 'cert', - Completion::TYPE_OPTION - ), - new Completion\ShellPathCompletion( - 'domain:add', - 'key', - Completion::TYPE_OPTION - ), - new Completion\ShellPathCompletion( - 'domain:add', - 'chain', - Completion::TYPE_OPTION - ), - new Completion\ShellPathCompletion( - 'local:build', - 'source', - Completion::TYPE_OPTION - ), - new Completion\ShellPathCompletion( - 'local:build', - 'destination', - Completion::TYPE_OPTION - ), - new Completion\ShellPathCompletion( - 'environment:sql-dump', - 'file', - Completion::TYPE_OPTION - ), - new Completion\ShellPathCompletion( - 'local:init', - 'directory', - Completion::TYPE_ARGUMENT - ), - new Completion( - Completion::ALL_COMMANDS, - 'app', - Completion::TYPE_OPTION, - [$this, 'getAppNames'] - ), - new Completion( - Completion::ALL_COMMANDS, - 'app', - Completion::TYPE_OPTION, - [$this, 'getAppNames'] - ), - new Completion\ShellPathCompletion( - Completion::ALL_COMMANDS, - 'identity-file', - Completion::TYPE_OPTION - ), - new Completion\ShellPathCompletion( - 'server:run', - 'log', - Completion::TYPE_OPTION - ), - new Completion\ShellPathCompletion( - 'server:start', - 'log', - Completion::TYPE_OPTION - ), - new Completion\ShellPathCompletion( - 'service:mongo:restore', - 'archive', - Completion::TYPE_ARGUMENT - ), - new Completion\ShellPathCompletion( - 'integration:add', - 'file', - Completion::TYPE_OPTION - ), - new Completion\ShellPathCompletion( - 'integration:update', - 'file', - Completion::TYPE_OPTION - ), - ]); - - try { - return $this->handler->runCompletion(); - } catch (\Exception $e) { - // Suppress exceptions so that they are not displayed during - // completion. - } - - return []; - } - - /** - * @return WelcomeCommand - */ - protected function getWelcomeCommand() - { - if (!isset($this->welcomeCommand)) { - $this->welcomeCommand = new WelcomeCommand('welcome'); - $this->welcomeCommand->setApplication($this->getApplication()); - } - - return $this->welcomeCommand; - } - - /** - * Get a list of environments IDs that can be checked out. - * - * @return string[] - */ - public function getEnvironmentsForCheckout() - { - $project = $this->getWelcomeCommand()->getCurrentProject(true); - if (!$project) { - return []; - } - try { - $currentEnvironment = $this->getWelcomeCommand()->getCurrentEnvironment($project, false); - } catch (\Exception $e) { - $currentEnvironment = false; - } - $environments = $this->api->getEnvironments($project, false, false); - if ($currentEnvironment) { - $environments = array_filter( - $environments, - function ($environment) use ($currentEnvironment) { - return $environment->id !== $currentEnvironment->id; - } - ); - } - - return array_keys($environments); - } - - /** - * Get a list of application names in the local project. - * - * @return string[] - */ - public function getAppNames() - { - $apps = []; - if ($projectRoot = $this->getWelcomeCommand()->getProjectRoot()) { - $finder = new ApplicationFinder(); - foreach ($finder->findApplications($projectRoot) as $app) { - $name = $app->getName(); - if ($name !== null) { - $apps[] = $name; - } - } - } elseif ($project = $this->getProject()) { - $environments = $this->api->getEnvironments($project, false); - if ($environments && ($environment = $this->api->getDefaultEnvironment($environments, $project))) { - $apps = array_keys($environment->getSshUrls()); - } - } - - return $apps; - } - - /** - * Get the preferred project for autocompletion. - * - * The project is either defined by an ID that the user has specified in - * the command (via the 'project' argument or '--project' option), or it is - * determined from the current path. - * - * @return Project|false - */ - protected function getProject() - { - $commandLine = $this->handler->getContext() - ->getCommandLine(); - $currentProjectId = $this->getProjectIdFromCommandLine($commandLine); - if (!$currentProjectId && ($currentProject = $this->getWelcomeCommand()->getCurrentProject(true))) { - return $currentProject; - } - - return $this->api->getProject($currentProjectId, null, false); - } - - /** - * Get a list of environment IDs. - * - * @return string[] - */ - public function getEnvironments() - { - $project = $this->getProject(); - if (!$project) { - return []; - } - - return array_keys($this->api->getEnvironments($project, false, false)); - } - - /** - * Get the project ID the user has already entered on the command line. - * - * @param string $commandLine - * - * @return string|false - */ - protected function getProjectIdFromCommandLine($commandLine) - { - if (preg_match('/\W(\-\-project|\-p|get) ?=? ?[\'"]?([0-9a-z]+)[\'"]?/', $commandLine, $matches)) { - return $matches[2]; - } - - return false; - } -} diff --git a/src/Command/Db/DbDumpCommand.php b/src/Command/Db/DbDumpCommand.php index 18dd28bb29..053b1c6812 100644 --- a/src/Command/Db/DbDumpCommand.php +++ b/src/Command/Db/DbDumpCommand.php @@ -1,25 +1,38 @@ setName('db:dump') - ->setDescription('Create a local dump of the remote database'); $this->addOption('schema', null, InputOption::VALUE_REQUIRED, 'The schema to dump. Omit to use the default schema (usually "main").') ->addOption('file', 'f', InputOption::VALUE_REQUIRED, 'A custom filename for the dump') ->addOption('directory', 'd', InputOption::VALUE_REQUIRED, 'A custom directory for the dump') @@ -30,7 +43,10 @@ protected function configure() ->addOption('exclude-table', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Table(s) to exclude') ->addOption('schema-only', null, InputOption::VALUE_NONE, 'Dump only schemas, no data') ->addOption('charset', null, InputOption::VALUE_REQUIRED, 'The character set encoding for the dump'); - $this->addProjectOption()->addEnvironmentOption()->addAppOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->selector->addAppOption($this->getDefinition()); + $this->addCompleter($this->selector); Relationships::configureInput($this->getDefinition()); Ssh::configureInput($this->getDefinition()); $this->setHiddenAliases(['sql-dump', 'environment:sql-dump']); @@ -38,47 +54,41 @@ protected function configure() $this->addExample('Create a gzipped SQL dump file named "dump.sql.gz"', '--gzip -f dump.sql.gz'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - /** @var \Platformsh\Cli\Service\Relationships $relationships */ - $relationships = $this->getService('relationships'); - - $host = $this->selectHost($input, $relationships->hasLocalEnvVar()); - if ($host instanceof LocalHost && $this->api()->isLoggedIn()) { - $this->chooseEnvFilter = $this->filterEnvsMaybeActive(); - $this->validateInput($input, true); - } + $selectorConfig = new SelectorConfig( + envRequired: false, + allowLocalHost: $this->relationships->hasLocalEnvVar(), + chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive(), + ); + // TODO check if this still allows offline use from the container + $selection = $this->selector->getSelection($input, $selectorConfig); + $host = $this->selector->getHostFromSelection($input, $selection); $timestamp = $input->getOption('timestamp') ? date('Ymd-His-T') : null; $gzip = $input->getOption('gzip'); $includedTables = $input->getOption('table'); $excludedTables = $input->getOption('exclude-table'); $schemaOnly = $input->getOption('schema-only'); - $projectRoot = $this->getProjectRoot(); - - /** @var \Platformsh\Cli\Service\Filesystem $fs */ - $fs = $this->getService('fs'); - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); + $projectRoot = $this->selector->getProjectRoot(); - $database = $relationships->chooseDatabase($host, $input, $output); + $database = $this->relationships->chooseDatabase($host, $input, $output); if (empty($database)) { return 1; } $service = false; - if ($this->hasSelectedEnvironment()) { + if ($selection->hasEnvironment()) { // Get information about the deployed service associated with the // selected relationship. - $deployment = $this->api()->getCurrentDeployment($this->getSelectedEnvironment()); + $deployment = $this->api->getCurrentDeployment($selection->getEnvironment()); $service = isset($database['service']) ? $deployment->getService($database['service']) : false; } $schema = $input->getOption('schema'); if (empty($schema)) { // Get a list of schemas (database names) from the service configuration. - $schemas = $service ? $relationships->getServiceSchemas($service) : []; + $schemas = $service ? $this->relationships->getServiceSchemas($service) : []; // Filter the list by the schemas accessible from the endpoint. if (isset($database['rel']) @@ -86,7 +96,7 @@ protected function execute(InputInterface $input, OutputInterface $output) && isset($service->configuration['endpoints'][$database['rel']]['privileges'])) { $schemas = array_intersect( $schemas, - array_keys($service->configuration['endpoints'][$database['rel']]['privileges']) + array_keys($service->configuration['endpoints'][$database['rel']]['privileges']), ); } @@ -105,9 +115,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } $schema = null; if (!empty($choices)) { - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $schema = $questionHelper->choose($choices, 'Enter a number to choose a schema:', $database['path'], true); + $schema = $this->questionHelper->choose($choices, 'Enter a number to choose a schema:', $database['path'], true); } if (empty($schema)) { $this->stdErr->writeln('The --schema is required.'); @@ -129,7 +137,7 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - $dumpFile = rtrim($fileOption, '/'); + $dumpFile = rtrim((string) $fileOption, '/'); if (!$gzip && preg_match('/\.gz$/i', $dumpFile)) { $this->stdErr->writeln('Warning: the filename ends with ".gz", but the dump will be plain-text.'); $this->stdErr->writeln('Use --gzip to create a compressed dump.'); @@ -137,13 +145,13 @@ protected function execute(InputInterface $input, OutputInterface $output) } } else { $defaultFilename = $this->getDefaultFilename( - $this->hasSelectedEnvironment() ? $this->getSelectedEnvironment() : null, + $selection->hasEnvironment() ? $selection->getEnvironment() : null, $database['service'], $schema, $includedTables, $excludedTables, $schemaOnly, - $gzip + $gzip, ); $dumpFile = $projectRoot ? $projectRoot . '/' . $defaultFilename : $defaultFilename; } @@ -155,12 +163,12 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - $dumpFile = rtrim($directoryOption, '/') . '/' . basename($dumpFile); + $dumpFile = rtrim((string) $directoryOption, '/') . '/' . basename($dumpFile); } // Insert a timestamp into the filename, before the // extension. - if ($timestamp !== null && strpos($dumpFile, $timestamp) === false) { + if ($timestamp !== null && !str_contains($dumpFile, $timestamp)) { $basename = basename($dumpFile); $prefix = substr($dumpFile, 0, - strlen($basename)); if (($dotPos = strpos($basename, '.')) > 0) { @@ -172,25 +180,25 @@ protected function execute(InputInterface $input, OutputInterface $output) } // Make the filename absolute. - $dumpFile = $fs->makePathAbsolute($dumpFile); + $dumpFile = $this->filesystem->makePathAbsolute($dumpFile); } if ($dumpFile) { if (file_exists($dumpFile)) { - if (!$questionHelper->confirm("File exists: $dumpFile. Overwrite?")) { + if (!$this->questionHelper->confirm("File exists: $dumpFile. Overwrite?")) { return 1; } } $this->stdErr->writeln(sprintf( 'Creating %s file: %s', $gzip ? 'gzipped SQL dump' : 'SQL dump', - $dumpFile + $dumpFile, )); } switch ($database['scheme']) { case 'pgsql': - $dumpCommand = 'pg_dump --no-owner --if-exists --clean --blobs ' . $relationships->getDbCommandArgs('pg_dump', $database, $schema); + $dumpCommand = 'pg_dump --no-owner --if-exists --clean --blobs ' . $this->relationships->getDbCommandArgs('pg_dump', $database, $schema); if ($schemaOnly) { $dumpCommand .= ' --schema-only'; } @@ -209,10 +217,10 @@ protected function execute(InputInterface $input, OutputInterface $output) break; default: - $cmdName = $relationships->isMariaDB($database) ? 'mariadb-dump' : 'mysqldump'; - $cmdInvocation = $relationships->mariaDbCommandWithFallback($cmdName); + $cmdName = $this->relationships->isMariaDB($database) ? 'mariadb-dump' : 'mysqldump'; + $cmdInvocation = $this->relationships->mariaDbCommandWithFallback($cmdName); $dumpCommand = $cmdInvocation . ' --single-transaction ' - . $relationships->getDbCommandArgs($cmdName, $database, $schema); + . $this->relationships->getDbCommandArgs($cmdName, $database, $schema); if ($schemaOnly) { $dumpCommand .= ' --no-data'; } @@ -221,9 +229,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } if ($includedTables) { $dumpCommand .= ' --tables ' - . implode(' ', array_map(function ($table) { - return OsUtil::escapePosixShellArg($table); - }, $includedTables)); + . implode(' ', array_map(fn($table) => OsUtil::escapePosixShellArg($table), $includedTables)); } if (!empty($service->configuration['properties']['max_allowed_packet'])) { $dumpCommand .= ' --max_allowed_packet=' . $service->configuration['properties']['max_allowed_packet'] . 'MB'; @@ -274,9 +280,8 @@ protected function execute(InputInterface $input, OutputInterface $output) // If a dump file exists, check that it's excluded in the project's // .gitignore configuration. - if ($dumpFile && file_exists($dumpFile) && $projectRoot && strpos($dumpFile, $projectRoot) === 0) { - /** @var \Platformsh\Cli\Service\Git $git */ - $git = $this->getService('git'); + if ($dumpFile && file_exists($dumpFile) && $projectRoot && str_starts_with($dumpFile, $projectRoot)) { + $git = $this->git; if (!$git->checkIgnore($dumpFile, $projectRoot)) { $this->stdErr->writeln('Warning: the dump file is not excluded by Git'); if ($pos = strrpos($dumpFile, '--dump.sql')) { @@ -291,28 +296,21 @@ protected function execute(InputInterface $input, OutputInterface $output) } /** - * Get the default dump filename. + * Generates the default dump filename. * - * @param Environment $environment - * @param string|null $dbServiceName - * @param string|null $schema - * @param array $includedTables - * @param array $excludedTables - * @param bool $schemaOnly - * @param bool $gzip - * - * @return string + * @param string[] $includedTables + * @param string[] $excludedTables */ private function getDefaultFilename( - Environment $environment = null, - $dbServiceName = null, - $schema = null, + ?Environment $environment = null, + ?string $dbServiceName = null, + ?string $schema = null, array $includedTables = [], array $excludedTables = [], - $schemaOnly = false, - $gzip = false) - { - $prefix = $this->config()->get('service.env_prefix'); + bool $schemaOnly = false, + bool $gzip = false, + ): string { + $prefix = $this->config->getStr('service.env_prefix'); $projectId = $environment ? $environment->project : getenv($prefix . 'PROJECT'); $environmentMachineName = $environment ? $environment->machine_name : getenv($prefix . 'ENVIRONMENT'); $defaultFilename = $projectId ?: 'db'; diff --git a/src/Command/Db/DbSizeCommand.php b/src/Command/Db/DbSizeCommand.php index 7e3fbaa01f..64d4392548 100644 --- a/src/Command/Db/DbSizeCommand.php +++ b/src/Command/Db/DbSizeCommand.php @@ -1,7 +1,16 @@ */ + private array $tableHeader = ['max' => 'Allocated disk', 'used' => 'Estimated usage', 'percent_used' => '% used']; + public function __construct(private readonly Api $api, private readonly Config $config, private readonly Io $io, private readonly QuestionHelper $questionHelper, private readonly Relationships $relationships, private readonly Selector $selector, private readonly Table $table) + { + parent::__construct(); + } - private $tableHeader = ['max' => 'Allocated disk', 'used' => 'Estimated usage', 'percent_used' => '% used']; - - const RED_WARNING_THRESHOLD = 90;//percentage - const YELLOW_WARNING_THRESHOLD = 80;//percentage - const BYTE_TO_MEGABYTE = 1048576; - const WASTED_SPACE_WARNING_THRESHOLD = 200;//percentage + public const RED_WARNING_THRESHOLD = 90;//percentage + public const YELLOW_WARNING_THRESHOLD = 80;//percentage + public const BYTE_TO_MEGABYTE = 1048576; + public const WASTED_SPACE_WARNING_THRESHOLD = 200;//percentage - const ESTIMATE_WARNING = 'This is an estimate of the database disk usage. The real size on disk is usually higher because of overhead.'; + public const ESTIMATE_WARNING = 'This is an estimate of the database disk usage. The real size on disk is usually higher because of overhead.'; - /** - * {@inheritDoc} - */ - protected function configure() { - $this->setName('db:size') - ->setDescription('Estimate the disk usage of a database') + protected function configure(): void + { + $this ->addOption('bytes', 'B', InputOption::VALUE_NONE, 'Show sizes in bytes.') ->addOption('cleanup', 'C', InputOption::VALUE_NONE, 'Check if tables can be cleaned up and show me recommendations (InnoDb only).'); $help = self::ESTIMATE_WARNING; - if ($this->config()->getWithDefault('api.metrics', false)) { + if ($this->config->getBool('api.metrics')) { $this->stability = self::STABILITY_DEPRECATED; $help .= "\n\n"; $help .= 'Deprecated:'; $help .= "\nThis command is deprecated and will be removed in a future version.\n"; - $help .= \sprintf('To see more accurate disk usage, run: %s disk', $this->config()->get('application.executable')); + $help .= \sprintf('To see more accurate disk usage, run: %s disk', $this->config->getStr('application.executable')); } $this->setHelp($help); - $this->addProjectOption()->addEnvironmentOption()->addAppOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->selector->addAppOption($this->getDefinition()); + $this->addCompleter($this->selector); Relationships::configureInput($this->getDefinition()); Table::configureInput($this->getDefinition(), $this->tableHeader); Ssh::configureInput($this->getDefinition()); @@ -52,17 +67,12 @@ protected function configure() { /** * {@inheritDoc} */ - protected function execute(InputInterface $input, OutputInterface $output) { - /** @var \Platformsh\Cli\Service\Relationships $relationships */ - $relationships = $this->getService('relationships'); - - $this->chooseEnvFilter = $this->filterEnvsMaybeActive(); - $this->validateInput($input); - $container = $this->selectRemoteContainer($input); - - $host = $this->selectHost($input, $relationships->hasLocalEnvVar(), $container); + protected function execute(InputInterface $input, OutputInterface $output): int + { + $selection = $this->selector->getSelection($input, new SelectorConfig(allowLocalHost: $this->relationships->hasLocalEnvVar(), chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive())); + $host = $this->selector->getHostFromSelection($input, $selection); - $database = $relationships->chooseDatabase($host, $input, $output, ['mysql', 'pgsql', 'mongodb']); + $database = $this->relationships->chooseDatabase($host, $input, $output, ['mysql', 'pgsql', 'mongodb']); if (empty($database)) { $this->stdErr->writeln('No database selected.'); return 1; @@ -75,29 +85,26 @@ protected function execute(InputInterface $input, OutputInterface $output) { // Get information about the deployed service associated with the // selected relationship. - $deployment = $this->api()->getCurrentDeployment($this->getSelectedEnvironment()); + $deployment = $this->api->getCurrentDeployment($selection->getEnvironment()); $service = $deployment->getService($dbServiceName); $this->stdErr->writeln(sprintf('Checking database service %s...', $dbServiceName)); - $this->debug('Calculating estimated usage...'); - $allocatedDisk = $service->disk * self::BYTE_TO_MEGABYTE; + $this->io->debug('Calculating estimated usage...'); + $allocatedDisk = ((int) $service->disk) * self::BYTE_TO_MEGABYTE; $estimatedUsage = $this->getEstimatedUsage($host, $database); $percentageUsed = round($estimatedUsage * 100 / $allocatedDisk); - - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - $machineReadable = $table->formatIsMachineReadable(); + $machineReadable = $this->table->formatIsMachineReadable(); $showInBytes = $input->getOption('bytes') || $machineReadable; $values = [ - 'max' => $showInBytes ? $allocatedDisk : Helper::formatMemory($allocatedDisk), - 'used' => $showInBytes ? $estimatedUsage : Helper::formatMemory($estimatedUsage), + 'max' => $showInBytes ? (string) $allocatedDisk : Helper::formatMemory($allocatedDisk), + 'used' => $showInBytes ? (string) $estimatedUsage : Helper::formatMemory((int) $estimatedUsage), 'percent_used' => $this->formatPercentage($percentageUsed, $machineReadable), ]; $this->stdErr->writeln(''); - $table->render([$values], $this->tableHeader); + $this->table->render([$values], $this->tableHeader); $this->showWarnings($percentageUsed); @@ -113,35 +120,33 @@ protected function execute(InputInterface $input, OutputInterface $output) { /** * Returns a list of cleanup queries for a list of tables. * - * @param array $rows + * @param string[] $rows * * @see DbSizeCommand::checkInnoDbTablesInNeedOfOptimizing() * - * @return array + * @return string[] */ - private function getCleanupQueries(array $rows) { + private function getCleanupQueries(array $rows): array + { return array_filter( - array_map(function($row) { + array_map(function ($row): ?string { if (!strpos($row, "\t")) { return null; } - list($schema, $table) = explode("\t", $row); + [$schema, $table] = explode("\t", $row); return sprintf('ALTER TABLE `%s`.`%s` ENGINE="InnoDB";', $schema, $table); - }, $rows) + }, $rows), ); } /** * Displays a list of InnoDB tables that can be usefully cleaned up. * - * @param HostInterface $host - * @param array $database - * @param InputInterface $input - * - * @return void + * @param array $database */ - private function checkInnoDbTablesInNeedOfOptimizing($host, array $database, InputInterface $input) { + private function checkInnoDbTablesInNeedOfOptimizing(HostInterface $host, array $database, InputInterface $input): void + { $tablesNeedingCleanup = $host->runCommand($this->getMysqlCommand($database), true, true, $this->mysqlTablesInNeedOfOptimizing()); $queries = []; if (is_string($tablesNeedingCleanup)) { @@ -165,10 +170,7 @@ private function checkInnoDbTablesInNeedOfOptimizing($host, array $database, Inp $this->stdErr->writeln('Warning: Running these may lock up your database for several minutes.'); $this->stdErr->writeln("Only run these when you know what you're doing."); $this->stdErr->writeln(''); - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - if ($input->isInteractive() && $questionHelper->confirm('Do you want to run these queries now?', false)) { + if ($input->isInteractive() && $this->questionHelper->confirm('Do you want to run these queries now?', false)) { $mysqlCommand = $this->getMysqlCommand($database); foreach ($queries as $query) { $this->stdErr->write($query); @@ -181,12 +183,13 @@ private function checkInnoDbTablesInNeedOfOptimizing($host, array $database, Inp /** * Shows a warning about schemas not accessible through this relationship. * - * @param \Platformsh\Client\Model\Deployment\Service $service - * @param array $database + * @param Service $service + * @param array $database * * @return void */ - private function showInaccessibleSchemas(Service $service, array $database) { + private function showInaccessibleSchemas(Service $service, array $database): void + { // Find if not all the available schemas were accessible via this relationship. if (isset($database['rel']) && isset($service->configuration['endpoints'][$database['rel']]['privileges'])) { @@ -212,20 +215,21 @@ private function showInaccessibleSchemas(Service $service, array $database) { * * @return void */ - private function showWarnings($percentageUsed) { + private function showWarnings(int|float $percentageUsed): void + { if ($percentageUsed > self::RED_WARNING_THRESHOLD) { $this->stdErr->writeln(''); $this->stdErr->writeln('Warning:'); $this->stdErr->writeln('Databases tend to need extra space for starting up and temporary storage when running large queries.'); - $this->stdErr->writeln(sprintf('Please increase the allocated space in %s', $this->config()->get('service.project_config_dir') . '/services.yaml')); + $this->stdErr->writeln(sprintf('Please increase the allocated space in %s', $this->config->getStr('service.project_config_dir') . '/services.yaml')); } $this->stdErr->writeln(''); - if ($this->config()->getWithDefault('api.metrics', false) && $this->config()->isCommandEnabled('metrics:disk')) { + if ($this->config->getBool('api.metrics') && $this->config->isCommandEnabled('metrics:disk')) { $this->stdErr->writeln('Deprecated:'); $this->stdErr->writeln('This command is deprecated and will be removed in a future version.'); - $this->stdErr->writeln(\sprintf('To see more accurate disk usage, run: %s disk', $this->config()->get('application.executable'))); + $this->stdErr->writeln(\sprintf('To see more accurate disk usage, run: %s disk', $this->config->getStr('application.executable'))); } else { $this->stdErr->writeln('Warning:'); $this->stdErr->writeln(self::ESTIMATE_WARNING); @@ -237,7 +241,7 @@ private function showWarnings($percentageUsed) { * * @return string */ - private function psqlQuery() + private function psqlQuery(): string { //both these queries are wrong... //$query = 'SELECT SUM(pg_database_size(t1.datname)) as size FROM pg_database t1'; //does miss lots of data @@ -250,52 +254,53 @@ private function psqlQuery() /** * Returns the psql CLI client command. * - * @param array $database + * @param array $database * * @return string */ - private function getPsqlCommand(array $database) { - /** @var \Platformsh\Cli\Service\Relationships $relationships */ - $relationships = $this->getService('relationships'); - $dbUrl = $relationships->getDbCommandArgs('psql', $database, ''); + private function getPsqlCommand(array $database): string + { + $dbUrl = $this->relationships->getDbCommandArgs('psql', $database, ''); return sprintf( 'psql --echo-hidden -t --no-align %s', - $dbUrl + $dbUrl, ); } - private function getMongoDbCommand(array $database) { - /** @var \Platformsh\Cli\Service\Relationships $relationships */ - $relationships = $this->getService('relationships'); - $dbUrl = $relationships->getDbCommandArgs('mongo', $database); + /** + * @param array $database + * @return string + */ + private function getMongoDbCommand(array $database): string + { + $dbUrl = $this->relationships->getDbCommandArgs('mongo', $database); return sprintf( 'mongo %s --quiet --eval %s', $dbUrl, // See https://docs.mongodb.com/manual/reference/command/dbStats/ - OsUtil::escapePosixShellArg('db.stats().fsUsedSize') + OsUtil::escapePosixShellArg('db.stats().fsUsedSize'), ); } /** * Returns the mysql CLI client command. * - * @param array $database + * @param array $database * * @return string */ - private function getMysqlCommand(array $database) { - /** @var \Platformsh\Cli\Service\Relationships $relationships */ - $relationships = $this->getService('relationships'); - $cmdName = $relationships->isMariaDB($database) ? 'mariadb' : 'mysql'; - $cmdInvocation = $relationships->mariaDbCommandWithFallback($cmdName); - $connectionParams = $relationships->getDbCommandArgs($cmdName, $database, ''); + private function getMysqlCommand(array $database): string + { + $cmdName = $this->relationships->isMariaDB($database) ? 'mariadb' : 'mysql'; + $cmdInvocation = $this->relationships->mariaDbCommandWithFallback($cmdName); + $connectionParams = $this->relationships->getDbCommandArgs($cmdName, $database, ''); return sprintf( '%s %s --no-auto-rehash --raw --skip-column-names', $cmdInvocation, - $connectionParams + $connectionParams, ); } @@ -306,7 +311,7 @@ private function getMysqlCommand(array $database) { * * @return string */ - private function mysqlNonInnodbQuery($excludeInnoDb = true) + private function mysqlNonInnodbQuery(bool $excludeInnoDb = true): string { return 'SELECT' . ' (' @@ -324,7 +329,7 @@ private function mysqlNonInnodbQuery($excludeInnoDb = true) * * @return string */ - private function mysqlInnodbQuery() + private function mysqlInnodbQuery(): string { return 'SELECT SUM(ALLOCATED_SIZE) FROM information_schema.innodb_sys_tablespaces;'; } @@ -334,7 +339,8 @@ private function mysqlInnodbQuery() * * @return string */ - private function mysqlInnodbAllocatedSizeExists() { + private function mysqlInnodbAllocatedSizeExists(): string + { return 'SELECT count(COLUMN_NAME) FROM information_schema.COLUMNS WHERE table_schema ="information_schema" AND table_name="innodb_sys_tablespaces" AND column_name LIKE "ALLOCATED_SIZE";'; } @@ -343,43 +349,49 @@ private function mysqlInnodbAllocatedSizeExists() { * * @return string */ - private function mysqlTablesInNeedOfOptimizing() { + private function mysqlTablesInNeedOfOptimizing(): string + { /*, data_free, data_length, ((data_free+1)/(data_length+1))*100 as wasted_space_percentage*/ - return 'SELECT TABLE_SCHEMA, TABLE_NAME FROM information_schema.tables WHERE ENGINE = "InnoDB" AND TABLE_TYPE="BASE TABLE" AND ((data_free+1)/(data_length+1))*100 >= '.self::WASTED_SPACE_WARNING_THRESHOLD.' ORDER BY data_free DESC LIMIT 10'; + return 'SELECT TABLE_SCHEMA, TABLE_NAME FROM information_schema.tables WHERE ENGINE = "InnoDB" AND TABLE_TYPE="BASE TABLE" AND ((data_free+1)/(data_length+1))*100 >= ' . self::WASTED_SPACE_WARNING_THRESHOLD . ' ORDER BY data_free DESC LIMIT 10'; } /** * Estimates usage of a database. * * @param HostInterface $host - * @param array $database + * @param array $database * * @return float Estimated usage in bytes. */ - private function getEstimatedUsage(HostInterface $host, array $database) { - switch($database['scheme']) { - case 'pgsql': - return $this->getPgSqlUsage($host, $database); - case 'mongodb': - return $this->getMongoDbUsage($host, $database); - default: - return $this->getMySqlUsage($host, $database); - } + private function getEstimatedUsage(HostInterface $host, array $database): float + { + return match ($database['scheme']) { + 'pgsql' => $this->getPgSqlUsage($host, $database), + 'mongodb' => $this->getMongoDbUsage($host, $database), + default => $this->getMySqlUsage($host, $database), + }; } /** * Estimates usage of a PostgreSQL database. * * @param HostInterface $host - * @param array $database + * @param array $database * * @return float Estimated usage in bytes */ - private function getPgSqlUsage(HostInterface $host, array $database) { - return (float) $host->runCommand($this->getPsqlCommand($database), true, true, $this->psqlQuery()); + private function getPgSqlUsage(HostInterface $host, array $database): float + { + return (float) $host->runCommand($this->getPsqlCommand($database), input: $this->psqlQuery()); } - private function getMongoDbUsage(HostInterface $host, array $database) { + /** + * @param HostInterface $host + * @param array $database + * @return float + */ + private function getMongoDbUsage(HostInterface $host, array $database): float + { return (float) $host->runCommand($this->getMongoDbCommand($database)); } @@ -387,23 +399,24 @@ private function getMongoDbUsage(HostInterface $host, array $database) { * Estimates usage of a MySQL database. * * @param HostInterface $host - * @param array $database + * @param array $database * * @return float Estimated usage in bytes */ - private function getMySqlUsage(HostInterface $host, array $database) { - $this->debug('Getting MySQL usage...'); - $allocatedSizeSupported = $host->runCommand($this->getMysqlCommand($database), true, true, $this->mysqlInnodbAllocatedSizeExists()); + private function getMySqlUsage(HostInterface $host, array $database): float + { + $this->io->debug('Getting MySQL usage...'); + $allocatedSizeSupported = $host->runCommand($this->getMysqlCommand($database), input: $this->mysqlInnodbAllocatedSizeExists()); $innoDbSize = 0; if ($allocatedSizeSupported) { - $this->debug('Checking InnoDB separately for more accurate results...'); + $this->io->debug('Checking InnoDB separately for more accurate results...'); try { - $innoDbSize = $host->runCommand($this->getMysqlCommand($database), true, true, $this->mysqlInnodbQuery()); - } catch (\Symfony\Component\Process\Exception\RuntimeException $e) { + $innoDbSize = $host->runCommand($this->getMysqlCommand($database), input: $this->mysqlInnodbQuery()); + } catch (RuntimeException $e) { // Some configurations do not have PROCESS privilege(s) and thus have no access to the sys_tablespaces // table. Ignore MySQL's 1227 Access Denied error, and revert to the legacy calculation. if (stripos($e->getMessage(), 'access denied') !== false) { - $this->debug('InnoDB checks not available: ' . $e->getMessage()); + $this->io->debug('InnoDB checks not available: ' . $e->getMessage()); $allocatedSizeSupported = false; } else { throw $e; @@ -411,7 +424,7 @@ private function getMySqlUsage(HostInterface $host, array $database) { } } - $otherSizes = $host->runCommand($this->getMysqlCommand($database), true, true, $this->mysqlNonInnodbQuery((bool) $allocatedSizeSupported)); + $otherSizes = $host->runCommand($this->getMysqlCommand($database), input: $this->mysqlNonInnodbQuery((bool) $allocatedSizeSupported)); return (float) $otherSizes + (float) $innoDbSize; } @@ -424,7 +437,8 @@ private function getMySqlUsage(HostInterface $host, array $database) { * * @return string */ - private function formatPercentage($percentage, $machineReadable) { + private function formatPercentage(int|float $percentage, bool $machineReadable): string + { if ($machineReadable) { $format = '%d'; } elseif ($percentage > self::RED_WARNING_THRESHOLD) { diff --git a/src/Command/Db/DbSqlCommand.php b/src/Command/Db/DbSqlCommand.php index 6a7f8eef34..49ed6ec407 100644 --- a/src/Command/Db/DbSqlCommand.php +++ b/src/Command/Db/DbSqlCommand.php @@ -1,30 +1,43 @@ setName('db:sql') - ->setAliases(['sql']) - ->setDescription('Run SQL on the remote database') + parent::__construct(); + } + protected function configure(): void + { + $this ->addArgument('query', InputArgument::OPTIONAL, 'An SQL statement to execute') ->addOption('raw', null, InputOption::VALUE_NONE, 'Produce raw, non-tabular output'); $this->addOption('schema', null, InputOption::VALUE_REQUIRED, 'The schema to use. Omit to use the default schema (usually "main"). Pass an empty string to not use any schema.'); - $this->addProjectOption()->addEnvironmentOption()->addAppOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->selector->addAppOption($this->getDefinition()); + $this->addCompleter($this->selector); Relationships::configureInput($this->getDefinition()); Ssh::configureInput($this->getDefinition()); $this->addExample('Open an SQL console on the remote database'); @@ -33,38 +46,39 @@ protected function configure() $this->setHiddenAliases(['environment:sql']); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { if (!$input->getArgument('query') && $this->runningViaMulti) { throw new InvalidArgumentException('The query argument is required when running via "multi"'); } - /** @var \Platformsh\Cli\Service\Relationships $relationships */ - $relationships = $this->getService('relationships'); - $this->chooseEnvFilter = $this->filterEnvsMaybeActive(); - $host = $this->selectHost($input, $relationships->hasLocalEnvVar()); - if ($host instanceof LocalHost && $this->api()->isLoggedIn()) { - $this->validateInput($input); - } + $selectorConfig = new SelectorConfig( + envRequired: false, + allowLocalHost: $this->relationships->hasLocalEnvVar(), + chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive(), + ); + // TODO check if this still allows offline use from the container + $selection = $this->selector->getSelection($input, $selectorConfig); + $host = $this->selector->getHostFromSelection($input, $selection); - $database = $relationships->chooseDatabase($host, $input, $output); + $database = $this->relationships->chooseDatabase($host, $input, $output); if (empty($database)) { return 1; } $schema = $input->getOption('schema'); if ($schema === null) { - if ($this->hasSelectedEnvironment()) { + if ($selection->hasEnvironment()) { // Get information about the deployed service associated with the // selected relationship. - $deployment = $this->api()->getCurrentDeployment($this->getSelectedEnvironment()); + $deployment = $this->api->getCurrentDeployment($selection->getEnvironment()); $service = isset($database['service']) ? $deployment->getService($database['service']) : false; } else { $service = false; } // Get a list of schemas (database names) from the service configuration. - $schemas = $service ? $relationships->getServiceSchemas($service) : []; + $schemas = $service ? $this->relationships->getServiceSchemas($service) : []; // Filter the list by the schemas accessible from the endpoint. if (isset($database['rel']) @@ -72,7 +86,7 @@ protected function execute(InputInterface $input, OutputInterface $output) && isset($service->configuration['endpoints'][$database['rel']]['privileges'])) { $schemas = array_intersect( $schemas, - array_keys($service->configuration['endpoints'][$database['rel']]['privileges']) + array_keys($service->configuration['endpoints'][$database['rel']]['privileges']), ); } @@ -93,9 +107,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $choices[$schema] .= ' (default)'; } } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $schema = $questionHelper->choose($choices, 'Enter a number to choose a schema:', $default, true); + $schema = $this->questionHelper->choose($choices, 'Enter a number to choose a schema:', $default, true); $schema = $schema === '(none)' ? '' : $schema; } } @@ -104,7 +116,7 @@ protected function execute(InputInterface $input, OutputInterface $output) switch ($database['scheme']) { case 'pgsql': - $sqlCommand = 'psql ' . $relationships->getDbCommandArgs('psql', $database, $schema); + $sqlCommand = 'psql ' . $this->relationships->getDbCommandArgs('psql', $database, $schema); if ($query) { if ($input->getOption('raw')) { $sqlCommand .= ' -t'; @@ -114,9 +126,9 @@ protected function execute(InputInterface $input, OutputInterface $output) break; default: - $cmdName = $relationships->isMariaDB($database) ? 'mariadb' : 'mysql'; - $cmdInvocation = $relationships->mariaDbCommandWithFallback($cmdName); - $sqlCommand = $cmdInvocation . ' --no-auto-rehash ' . $relationships->getDbCommandArgs($cmdName, $database, $schema); + $cmdName = $this->relationships->isMariaDB($database) ? 'mariadb' : 'mysql'; + $cmdInvocation = $this->relationships->mariaDbCommandWithFallback($cmdName); + $sqlCommand = $cmdInvocation . ' --no-auto-rehash ' . $this->relationships->getDbCommandArgs($cmdName, $database, $schema); if ($query) { if ($input->getOption('raw')) { $sqlCommand .= ' --batch --raw'; @@ -127,7 +139,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } // Enable tabular output when the input is a terminal. - if (!$input->getOption('raw') && $host instanceof RemoteHost && $this->isTerminal(STDIN)) { + if (!$input->getOption('raw') && $host instanceof RemoteHost && $this->io->isTerminal(STDIN)) { $host->setExtraSshOptions(['RequestTTY yes']); } diff --git a/src/Command/DecodeCommand.php b/src/Command/DecodeCommand.php index d4de1914ed..1a7ac74d60 100644 --- a/src/Command/DecodeCommand.php +++ b/src/Command/DecodeCommand.php @@ -1,42 +1,49 @@ config()->get('service.env_prefix'); + $envPrefix = $this->config->getStr('service.env_prefix'); $this - ->setName('decode') - ->addArgument('value', InputArgument::REQUIRED, 'The variable value to decode') - ->addOption('property', 'P', InputOption::VALUE_REQUIRED, 'The property to view within the variable') - ->setDescription(sprintf('Decode an encoded string such as %sVARIABLES', $envPrefix)); + ->addArgument('value', InputArgument::REQUIRED, 'The value to decode') + ->addOption('property', 'P', InputOption::VALUE_REQUIRED, 'The property to view within the value'); $this->addExample( sprintf('View "foo" in %sVARIABLES', $envPrefix), - sprintf('"$%sVARIABLES" -P foo', $envPrefix) + sprintf('"$%sVARIABLES" -P foo', $envPrefix), ); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $variable = $input->getArgument('value'); - if (trim($variable) === '') { + if (trim((string) $variable) === '') { $this->stdErr->writeln('Failed to decode: the provided value is empty.'); return 1; } - $b64decoded = base64_decode($variable, true); + $b64decoded = base64_decode((string) $variable, true); if ($b64decoded === false) { $this->stdErr->writeln('Invalid value: base64 decoding failed.'); @@ -67,7 +74,7 @@ protected function execute(InputInterface $input, OutputInterface $output) if (array_key_exists($property, $decoded)) { $value = $decoded[$property]; } else { - $value = NestedArrayUtil::getNestedArrayValue($decoded, explode('.', $property), $keyExists); + $value = NestedArrayUtil::getNestedArrayValue($decoded, explode('.', (string) $property), $keyExists); if (!$keyExists) { $this->stdErr->writeln('Property not found: ' . $property . ''); @@ -85,7 +92,7 @@ protected function execute(InputInterface $input, OutputInterface $output) if (is_string($value)) { $output->writeln($value); } else { - $output->writeln(json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + $output->writeln((string) json_encode($value, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); } return 0; diff --git a/src/Command/DocsCommand.php b/src/Command/DocsCommand.php index 388e9bb352..7561da3b5d 100644 --- a/src/Command/DocsCommand.php +++ b/src/Command/DocsCommand.php @@ -1,44 +1,48 @@ setName('docs') - ->setDescription('Open the online documentation') ->addArgument('search', InputArgument::IS_ARRAY, 'Search term(s)'); $this->addExample('Search for information about the CLI', 'CLI'); Url::configureInput($this->getDefinition()); } - public function isEnabled() + public function isEnabled(): bool { - return $this->config()->has('service.docs_url') - && $this->config()->has('service.docs_search_url') + return $this->config->has('service.docs_url') + && $this->config->has('service.docs_search_url') && parent::isEnabled(); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { if ($searchArguments = $input->getArgument('search')) { $query = $this->getSearchQuery($searchArguments); - $url = str_replace('{{ terms }}', rawurlencode($query), $this->config()->get('service.docs_search_url')); + $url = str_replace('{{ terms }}', rawurlencode($query), $this->config->getStr('service.docs_search_url')); } else { - $url = $this->config()->get('service.docs_url'); + $url = $this->config->getStr('service.docs_url'); } - - /** @var \Platformsh\Cli\Service\Url $urlService */ - $urlService = $this->getService('url'); - $urlService->openUrl($url); + $this->url->openUrl($url); + return 0; } /** @@ -51,10 +55,8 @@ protected function execute(InputInterface $input, OutputInterface $output) * * @return string */ - protected function getSearchQuery(array $args) + protected function getSearchQuery(array $args): string { - return implode(' ', array_map(function ($term) { - return strpos($term, ' ') ? '"' . $term . '"' : $term; - }, $args)); + return implode(' ', array_map(fn($term) => strpos((string) $term, ' ') ? '"' . $term . '"' : $term, $args)); } } diff --git a/src/Command/Domain/DomainAddCommand.php b/src/Command/Domain/DomainAddCommand.php index 0c6d2b62f0..81b395118c 100644 --- a/src/Command/Domain/DomainAddCommand.php +++ b/src/Command/Domain/DomainAddCommand.php @@ -1,105 +1,118 @@ setName('domain:add') - ->setDescription('Add a new domain to the project'); $this->addDomainOptions(); $this->addOption('attach', null, InputOption::VALUE_REQUIRED, "The production domain that this one replaces in the environment's routes. Required for non-production environment domains."); $this->addHiddenOption('replace', 'r', InputOption::VALUE_REQUIRED, 'Deprecated: this has been renamed to --attach'); - $this->addProjectOption() - ->addEnvironmentOption() - ->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); $this->addExample('Add the domain example.com', 'example.com'); $this->addExample( 'Add the domain example.org with a custom SSL/TLS certificate', - 'example.org --cert example-org.crt --key example-org.key' + 'example.org --cert example-org.crt --key example-org.key', ); } /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->warnAboutDeprecatedOptions(['replace'], 'The option --replace has been renamed to --attach.'); + $this->io->warnAboutDeprecatedOptions(['replace'], 'The option --replace has been renamed to --attach.'); - $this->validateInput($input, true); + $selectorConfig = new SelectorConfig(envRequired: false); + if ($this->isForEnvironment($input)) { + $selectorConfig = new SelectorConfig( + chooseEnvFilter: fn(Environment $e): bool => $e->type !== 'production', + ); + } + $selection = $this->selector->getSelection($input, $selectorConfig); - if (!$this->validateDomainInput($input)) { + if (!$this->validateDomainInput($input, $selection)) { return 1; } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - - $project = $this->getSelectedProject(); - $environment = $this->getSelectedEnvironment(); - $this->ensurePrintSelectedEnvironment(true); + $project = $selection->getProject(); + $environment = $selection->getEnvironment(); + $this->selector->ensurePrintedSelection($selection); $this->stdErr->writeln(sprintf('Adding the domain: %s', $this->domainName)); if (!empty($this->attach)) { $this->stdErr->writeln(sprintf('It will be attached to the production domain: %s', $this->attach)); } $this->stdErr->writeln(''); - if (!$questionHelper->confirm('Are you sure you want to continue?')) { + if (!$this->questionHelper->confirm('Are you sure you want to continue?')) { return 1; } - $httpClient = $this->api()->getHttpClient(); + $httpClient = $this->api->getHttpClient(); try { $result = EnvironmentDomain::add($httpClient, $environment, $this->domainName, $this->attach, $this->sslOptions); } catch (ClientException $e) { - $response = $e->getResponse(); - if ($response) { - $code = $response->getStatusCode(); - if ($code === 402) { - $data = $response->json(); - if (isset($data['message'], $data['detail']['environments_with_domains_limit'], $data['detail']['environments_with_domains'])) { - $this->stdErr->writeln(''); - $this->stdErr->writeln($data['message']); - if (!empty($data['detail']['environments_with_domains'])) { - $this->stdErr->writeln('Environments with domains: ' . implode(', ', $data['detail']['environments_with_domains']) . ''); - } - return 1; + $code = $e->getResponse()->getStatusCode(); + if ($code === 402) { + $data = (array) Utils::jsonDecode((string) $e->getResponse()->getBody(), true); + if (isset($data['message'], $data['detail']['environments_with_domains_limit'], $data['detail']['environments_with_domains'])) { + $this->stdErr->writeln(''); + $this->stdErr->writeln($data['message']); + if (!empty($data['detail']['environments_with_domains'])) { + $this->stdErr->writeln('Environments with domains: ' . implode(', ', $data['detail']['environments_with_domains']) . ''); } + return 1; } - if ($code === 409) { - $data = $response->json(); - if (isset($data['message'], $data['detail']['conflicting_domain']) && strpos($data['message'], 'already has a domain with the same replacement_for') !== false) { - $this->stdErr->writeln(''); - $this->stdErr->writeln(sprintf( - 'The environment %s already has a domain with the same --attach value: %s', - $this->api()->getEnvironmentLabel($environment, 'comment'), $data['detail']['conflicting_domain'] - )); - return 1; - } - if (isset($data['message'], $data['detail']['prod-domains']) && strpos($data['message'], 'has no corresponding domain set on the production environment') !== false) { + } + if ($code === 409) { + $data = (array) Utils::jsonDecode((string) $e->getResponse()->getBody(), true); + if (isset($data['message'], $data['detail']['conflicting_domain']) && str_contains((string) $data['message'], 'already has a domain with the same replacement_for')) { + $this->stdErr->writeln(''); + $this->stdErr->writeln(sprintf( + 'The environment %s already has a domain with the same --attach value: %s', + $this->api->getEnvironmentLabel($environment, 'comment'), + $data['detail']['conflicting_domain'], + )); + return 1; + } + if (isset($data['message'], $data['detail']['prod-domains']) && str_contains((string) $data['message'], 'has no corresponding domain set on the production environment')) { + $this->stdErr->writeln(''); + $this->stdErr->writeln(sprintf( + 'The --attach domain does not exist on a production environment: %s', + $this->attach, + )); + if (!empty($data['detail']['prod-domains'])) { $this->stdErr->writeln(''); - $this->stdErr->writeln(sprintf( - 'The --attach domain does not exist on a production environment: %s', - $this->attach - )); - if (!empty($data['detail']['prod-domains'])) { - $this->stdErr->writeln(''); - $this->stdErr->writeln("Production environment domains:\n " . implode("\n ", $data['detail']['prod-domains']) . ''); - } - return 1; + $this->stdErr->writeln("Production environment domains:\n " . implode("\n ", $data['detail']['prod-domains']) . ''); } + return 1; } } $this->handleApiException($e, $project); @@ -107,9 +120,8 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - if ($this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); + if ($this->activityMonitor->shouldWait($input)) { + $activityMonitor = $this->activityMonitor; $activityMonitor->waitMultiple($result->getActivities(), $project); } diff --git a/src/Command/Domain/DomainCommandBase.php b/src/Command/Domain/DomainCommandBase.php index 70406adc04..901c0590eb 100644 --- a/src/Command/Domain/DomainCommandBase.php +++ b/src/Command/Domain/DomainCommandBase.php @@ -1,11 +1,20 @@ api = $api; + $this->config = $config; + $this->questionHelper = $questionHelper; + $this->selector = $selector; + } - protected $attach; + protected function isForEnvironment(InputInterface $input): bool + { + return ($input->hasOption('environment') && $input->getOption('environment') !== null) + || ($input->hasOption('attach') && $input->getOption('attach') !== null) + || ($input->hasOption('replace') && $input->getOption('replace') !== null); + } - /** - * @param InputInterface $input - * - * @return bool - */ - protected function validateDomainInput(InputInterface $input) + protected function validateDomainInput(InputInterface $input, Selection $selection): bool { $this->domainName = $input->getArgument('name'); if (!$this->validDomain($this->domainName)) { @@ -54,25 +77,22 @@ protected function validateDomainInput(InputInterface $input) } if ($input->hasOption('environment') || $input->hasOption('attach')) { - $project = $this->getSelectedProject(); - $forEnvironment = ($input->hasOption('environment') && $input->getOption('environment') !== null) - || ($input->hasOption('attach') && $input->getOption('attach') !== null) - || ($input->hasOption('replace') && $input->getOption('replace') !== null); - + $project = $selection->getProject(); $supportsNonProduction = $this->supportsNonProductionDomains($project); - if ($forEnvironment) { - $this->selectEnvironment($input->getOption('environment'), true, false, true, function (Environment $e) use ($project) { - return $e->type !== 'production' && $e->id !== $project->default_branch; - }); - $environment = $this->getSelectedEnvironment(); - $this->environmentIsProduction = $environment->id === $project->default_branch; - $this->ensurePrintSelectedEnvironment(true); + if ($this->isForEnvironment($input)) { + $environment = $selection->getEnvironment(); + $this->environmentIsProduction = $environment->type === 'production' || $environment->id === $project->default_branch; + $this->selector->ensurePrintedSelection($selection); } elseif ($project->default_branch === null) { $this->stdErr->writeln('The default_branch property is not set on the project, so the production environment cannot be determined'); return false; } else { - $this->selectEnvironment($project->default_branch, true, false, false); + $environment = $this->api->getEnvironment($project->default_branch, $project); + if (!$environment) { + $this->stdErr->writeln(sprintf('Environment not found: %s', $project->default_branch)); + return false; + } $this->environmentIsProduction = true; if ($input->hasOption('attach') && $supportsNonProduction) { $this->stdErr->writeln('Use the --environment option (and optionally --attach) to add a domain to a non-production environment.'); @@ -87,19 +107,19 @@ protected function validateDomainInput(InputInterface $input) return false; } if (!$this->environmentIsProduction && !$supportsNonProduction) { - $this->stdErr->writeln(sprintf('The project %s does not support non-production environment domains.', $this->api()->getProjectLabel($project, 'error'))); - if ($this->config()->has('warnings.non_production_domains_msg')) { - $this->stdErr->writeln("\n". trim($this->config()->get('warnings.non_production_domains_msg'))); + $this->stdErr->writeln(sprintf('The project %s does not support non-production environment domains.', $this->api->getProjectLabel($project, 'error'))); + if ($this->config->has('warnings.non_production_domains_msg')) { + $this->stdErr->writeln("\n" . trim($this->config->getStr('warnings.non_production_domains_msg'))); } return false; } if (!$this->environmentIsProduction && $this->attach === null) { - $project = $this->getSelectedProject(); + $project = $selection->getProject(); try { $productionDomains = $project->getDomains(); $productionDomainAccess = true; } catch (BadResponseException $e) { - if ($e->getResponse() && $e->getResponse()->getStatusCode() === 403) { + if ($e->getResponse()->getStatusCode() === 403) { $productionDomainAccess = false; $productionDomains = []; } else { @@ -129,13 +149,11 @@ protected function validateDomainInput(InputInterface $input) $choices[$productionDomain->name] = $productionDomain->name; } } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); $questionText = 'Attachment (--attach)' . "\nA non-production domain must be attached to an existing production domain." . "\nIt will inherit the same routing behavior." . "\nChoose a production domain:"; - $this->attach = $questionHelper->choose($choices, $questionText, $default); + $this->attach = $this->questionHelper->choose($choices, $questionText, $default); } } elseif ($this->attach !== null) { try { @@ -143,13 +161,13 @@ protected function validateDomainInput(InputInterface $input) if ($domain === false) { $this->stdErr->writeln(sprintf( 'The production domain (--attach) was not found: %s', - $this->attach + $this->attach, )); return false; } } catch (BadResponseException $e) { // Ignore access denied errors. - if (!$e->getResponse() || $e->getResponse()->getStatusCode() !== 403) { + if ($e->getResponse()->getStatusCode() !== 403) { throw $e; } } @@ -160,7 +178,7 @@ protected function validateDomainInput(InputInterface $input) return true; } - protected function addDomainOptions() + protected function addDomainOptions(): void { $this->addArgument('name', InputArgument::REQUIRED, 'The domain name') ->addOption('cert', null, InputOption::VALUE_REQUIRED, 'The path to a custom certificate file') @@ -169,16 +187,11 @@ protected function addDomainOptions() } /** - * Validate a domain. - * - * @param string $domain - * - * @return bool + * Validates a domain name. */ - protected function validDomain($domain) + private function validDomain(string $domain): bool { - // @todo: Use symfony/Validator here once it gets the ability to validate just domain. - return (bool) preg_match('/^([^\.]{1,63}\.)+[^\.]{2,63}$/', $domain); + return (bool) preg_match('/^([^.]{1,63}\.)+[^.]{2,63}$/', $domain); } /** @@ -189,12 +202,9 @@ protected function validDomain($domain) * * @throws ClientException If it can't be explained. */ - protected function handleApiException(ClientException $e, Project $project) + protected function handleApiException(ClientException $e, Project $project): void { $response = $e->getResponse(); - if (!$response) { - throw $e; - } if ($response->getStatusCode() === 403) { $project->ensureFull(); $data = $project->getData(); @@ -207,7 +217,7 @@ protected function handleApiException(ClientException $e, Project $project) } // @todo standardize API error parsing if the format is ever formalized if ($response->getStatusCode() === 400) { - $data = $response->json(); + $data = (array) Utils::jsonDecode((string) $response->getBody(), true); if (isset($data['detail']['error'])) { $this->stdErr->writeln($data['detail']['error']); return; @@ -218,12 +228,8 @@ protected function handleApiException(ClientException $e, Project $project) /** * Checks if a project supports non-production domains. - * - * @param Project $project - * - * @return bool */ - protected function supportsNonProductionDomains(Project $project) + protected function supportsNonProductionDomains(Project $project): bool { static $cache = []; if (!isset($cache[$project->id])) { diff --git a/src/Command/Domain/DomainDeleteCommand.php b/src/Command/Domain/DomainDeleteCommand.php index 99ea98fb11..0b5aa1e14b 100644 --- a/src/Command/Domain/DomainDeleteCommand.php +++ b/src/Command/Domain/DomainDeleteCommand.php @@ -1,46 +1,56 @@ setName('domain:delete') - ->setDescription('Delete a domain from the project') ->addArgument('name', InputArgument::REQUIRED, 'The domain name'); - $this->addProjectOption() - ->addEnvironmentOption() - ->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); $this->addExample('Delete the domain example.com', 'example.com'); } /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input, true); + $selection = $this->selector->getSelection($input, new SelectorConfig(envRequired: false)); $forEnvironment = $input->getOption('environment') !== null; $name = $input->getArgument('name'); - $project = $this->getSelectedProject(); + $project = $selection->getProject(); if ($forEnvironment) { - $httpClient = $this->api()->getHttpClient(); - $environment = $this->getSelectedEnvironment(); + $httpClient = $this->api->getHttpClient(); + $environment = $selection->getEnvironment(); $domain = EnvironmentDomain::get($name, $environment->getLink('#domains'), $httpClient); - } - else { + } else { $domain = $project->getDomain($name); } @@ -56,12 +66,10 @@ protected function execute(InputInterface $input, OutputInterface $output) // because looping through all the non-production environments to fetch // their domains would not be scalable. $isProductionDomain = $domain->getProperty('type', false) === 'production' - || (!$forEnvironment || $this->getSelectedEnvironment()->type === 'production'); + || (!$forEnvironment || $selection->getEnvironment()->type === 'production'); if ($isProductionDomain && $this->supportsNonProductionDomains($project)) { // Check the project has at least 1 non-inactive, non-production environment. - $hasNonProductionActiveEnvs = count(array_filter($this->api()->getEnvironments($project), function (Environment $e) { - return $e->type !== 'production' && $e->status !== 'inactive'; - })) > 0; + $hasNonProductionActiveEnvs = count(array_filter($this->api->getEnvironments($project), fn(Environment $e): bool => $e->type !== 'production' && $e->status !== 'inactive')) > 0; if ($hasNonProductionActiveEnvs) { $this->stdErr->writeln([ 'Warning:', @@ -72,10 +80,7 @@ protected function execute(InputInterface $input, OutputInterface $output) ]); } } - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - if (!$questionHelper->confirm("Are you sure you want to delete the domain $name?")) { + if (!$this->questionHelper->confirm("Are you sure you want to delete the domain $name?")) { return 1; } $this->stdErr->writeln(''); @@ -84,9 +89,8 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln("The domain $name has been deleted."); - if ($this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); + if ($this->activityMonitor->shouldWait($input)) { + $activityMonitor = $this->activityMonitor; $activityMonitor->waitMultiple($result->getActivities(), $project); } diff --git a/src/Command/Domain/DomainGetCommand.php b/src/Command/Domain/DomainGetCommand.php index 910d39d20f..2aece5ac73 100644 --- a/src/Command/Domain/DomainGetCommand.php +++ b/src/Command/Domain/DomainGetCommand.php @@ -1,44 +1,54 @@ setName('domain:get') - ->setDescription('Show detailed information for a domain') ->addArgument('name', InputArgument::OPTIONAL, 'The domain name') ->addOption('property', 'P', InputOption::VALUE_REQUIRED, 'The domain property to view'); Table::configureInput($this->getDefinition()); PropertyFormatter::configureInput($this->getDefinition()); - $this->addProjectOption() - ->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); } /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input, true); - $project = $this->getSelectedProject(); + $selection = $this->selector->getSelection($input, new SelectorConfig(envRequired: false)); + $project = $selection->getProject(); $forEnvironment = $input->getOption('environment') !== null; - $environment = $forEnvironment ? $this->getSelectedEnvironment() : null; - $httpClient = $this->api()->getHttpClient(); + $environment = $forEnvironment ? $selection->getEnvironment() : null; + $httpClient = $this->api->getHttpClient(); $domainName = $input->getArgument('name'); if (!empty($domainName)) { @@ -60,18 +70,13 @@ protected function execute(InputInterface $input, OutputInterface $output) $options[$domain->name] = $domain->name; $byName[$domain->name] = $domain; } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $domainName = $questionHelper->choose($options, 'Enter a number to choose a domain:'); + $domainName = $this->questionHelper->choose($options, 'Enter a number to choose a domain:'); $domain = $byName[$domainName]; } - /** @var \Platformsh\Cli\Service\PropertyFormatter $propertyFormatter */ - $propertyFormatter = $this->getService('property_formatter'); - if ($property = $input->getOption('property')) { - $value = $this->api()->getNestedProperty($domain, $property); - $output->writeln($propertyFormatter->format($value, $property)); + $value = $this->api->getNestedProperty($domain, $property); + $output->writeln($this->propertyFormatter->format($value, $property)); return 0; } @@ -84,17 +89,15 @@ protected function execute(InputInterface $input, OutputInterface $output) continue; } $properties[] = $name; - $values[] = $propertyFormatter->format($value, $name); + $values[] = $this->propertyFormatter->format($value, $name); } - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - $table->renderSimple($values, $properties); + $this->table->renderSimple($values, $properties); $this->stdErr->writeln(''); - $executable = $this->config()->get('application.executable'); + $executable = $this->config->getStr('application.executable'); $exampleArgs = ''; if ($forEnvironment) { - $exampleArgs = '-e ' . OsUtil::escapeShellArg($this->getSelectedEnvironment()->name) . ' '; + $exampleArgs = '-e ' . OsUtil::escapeShellArg($selection->getEnvironment()->name) . ' '; } $exampleArgs .= OsUtil::escapeShellArg($domainName); $this->stdErr->writeln([ diff --git a/src/Command/Domain/DomainListCommand.php b/src/Command/Domain/DomainListCommand.php index 0ac2ed53d5..1e69872084 100644 --- a/src/Command/Domain/DomainListCommand.php +++ b/src/Command/Domain/DomainListCommand.php @@ -1,17 +1,28 @@ */ + private array $tableHeader = [ 'name' => 'Name', 'ssl' => 'SSL enabled', 'created_at' => 'Creation date', @@ -20,19 +31,19 @@ class DomainListCommand extends DomainCommandBase 'replacement_for' => 'Attached domain', 'type' => 'Type', ]; - private $defaultColumns = ['name', 'ssl', 'created_at']; + /** @var string[] */ + private array $defaultColumns = ['name', 'ssl', 'created_at']; + public function __construct(private readonly Api $api, private readonly Config $config, private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table) + { + parent::__construct(); + } - /** - * {@inheritdoc} - */ - protected function configure() + protected function configure(): void { - $this - ->setName('domain:list') - ->setAliases(['domains']) - ->setDescription('Get a list of all domains'); Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); - $this->addProjectOption()->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); } /** @@ -40,21 +51,18 @@ protected function configure() * * @param Domain[]|EnvironmentDomain[] $tree * - * @return array + * @return array> */ - protected function buildDomainRows(array $tree) + protected function buildDomainRows(array $tree): array { $rows = []; - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - foreach ($tree as $domain) { $rows[] = [ 'name' => $domain->id, - 'ssl' => $formatter->format((bool) $domain['ssl']['has_certificate']), - 'created_at' => $formatter->format($domain['created_at'], 'created_at'), - 'updated_at' => $formatter->format($domain['updated_at'], 'updated_at'), + 'ssl' => $this->propertyFormatter->format((bool) $domain['ssl']['has_certificate']), + 'created_at' => $this->propertyFormatter->format($domain['created_at'], 'created_at'), + 'updated_at' => $this->propertyFormatter->format($domain['updated_at'], 'updated_at'), 'registered_name' => $domain->getProperty('registered_name', false, false) ?: '', 'replacement_for' => $domain->getProperty('replacement_for', false, false) ?: '', 'type' => $domain->getProperty('type', false, false) ?: '', @@ -67,20 +75,20 @@ protected function buildDomainRows(array $tree) /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input, true); + $selection = $this->selector->getSelection($input, new SelectorConfig(envRequired: false)); $forEnvironment = $input->getOption('environment') !== null; - $project = $this->getSelectedProject(); - $executable = $this->config()->get('application.executable'); + $project = $selection->getProject(); + $executable = $this->config->getStr('application.executable'); $defaultColumns = $this->defaultColumns; if ($forEnvironment) { $defaultColumns[] = 'replacement_for'; - $httpClient = $this->api()->getHttpClient(); + $httpClient = $this->api->getHttpClient(); try { - $domains = EnvironmentDomain::getList($this->getSelectedEnvironment(), $httpClient); + $domains = EnvironmentDomain::getList($selection->getEnvironment(), $httpClient); } catch (ClientException $e) { $this->handleApiException($e, $project); return 1; @@ -96,64 +104,60 @@ protected function execute(InputInterface $input, OutputInterface $output) if (empty($domains)) { if ($forEnvironment) { - $environment = $this->getSelectedEnvironment(); + $environment = $selection->getEnvironment(); $this->stdErr->writeln(sprintf( 'No domains found for the environment %s on the project %s.', - $this->api()->getEnvironmentLabel($environment), - $this->api()->getProjectLabel($project) + $this->api->getEnvironmentLabel($environment), + $this->api->getProjectLabel($project), )); $this->stdErr->writeln(''); if ($environment->is_main) { $this->stdErr->writeln(sprintf( 'Add a domain to the environment by running %s domain:add -e %s [domain-name]', $executable, - OsUtil::escapeShellArg($environment->name) + OsUtil::escapeShellArg($environment->name), )); } else { $this->stdErr->writeln(sprintf( 'Add a domain to the environment by running %s domain:add -e %s [domain-name] --attach [attach]', $executable, - OsUtil::escapeShellArg($environment->name) + OsUtil::escapeShellArg($environment->name), )); } - } - else { - $this->stdErr->writeln('No domains found for ' . $this->api()->getProjectLabel($project) . '.'); + } else { + $this->stdErr->writeln('No domains found for ' . $this->api->getProjectLabel($project) . '.'); $this->stdErr->writeln(''); $this->stdErr->writeln( - 'Add a domain to the project by running ' . $executable . ' domain:add [domain-name]' + 'Add a domain to the project by running ' . $executable . ' domain:add [domain-name]', ); } return 1; } - - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); $rows = $this->buildDomainRows($domains); - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { if ($forEnvironment) { $this->stdErr->writeln(sprintf( 'Domains on the project %s, environment %s:', - $this->api()->getProjectLabel($project), - $this->api()->getEnvironmentLabel($this->getSelectedEnvironment()) + $this->api->getProjectLabel($project), + $this->api->getEnvironmentLabel($selection->getEnvironment()), )); } else { $this->stdErr->writeln(sprintf( 'Domains on the project %s:', - $this->api()->getProjectLabel($project) + $this->api->getProjectLabel($project), )); } } - $table->render($rows, $this->tableHeader, $defaultColumns); + $this->table->render($rows, $this->tableHeader, $defaultColumns); - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->stdErr->writeln(''); if ($forEnvironment) { - $exampleAddArgs = $exampleArgs = '-e ' . OsUtil::escapeShellArg($this->getSelectedEnvironment()->name) . ' [domain-name]'; - if (!$this->getSelectedEnvironment()->is_main) { + $exampleAddArgs = $exampleArgs = '-e ' . OsUtil::escapeShellArg($selection->getEnvironment()->name) . ' [domain-name]'; + if (!$selection->getEnvironment()->is_main) { $exampleAddArgs .= ' --attach [attach]'; } } else { diff --git a/src/Command/Domain/DomainUpdateCommand.php b/src/Command/Domain/DomainUpdateCommand.php index 81bf850bd9..d361280955 100644 --- a/src/Command/Domain/DomainUpdateCommand.php +++ b/src/Command/Domain/DomainUpdateCommand.php @@ -1,49 +1,64 @@ setName('domain:update') - ->setDescription('Update a domain'); $this->addDomainOptions(); - $this->addProjectOption() - ->addEnvironmentOption() - ->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); $this->addExample( 'Update the custom certificate for the domain example.org', - 'example.org --cert example-org.crt --key example-org.key' + 'example.org --cert example-org.crt --key example-org.key', ); } /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input, true); + $selectorConfig = new SelectorConfig(envRequired: false); + if ($this->isForEnvironment($input)) { + $selectorConfig = new SelectorConfig( + chooseEnvFilter: fn(Environment $e): bool => $e->type !== 'production', + ); + } + $selection = $this->selector->getSelection($input, $selectorConfig); - if (!$this->validateDomainInput($input)) { + if (!$this->validateDomainInput($input, $selection)) { return 1; } $forEnvironment = $input->getOption('environment') !== null; - $environment = $forEnvironment ? $this->getSelectedEnvironment() : null; + $environment = $forEnvironment ? $selection->getEnvironment() : null; - $project = $this->getSelectedProject(); + $project = $selection->getProject(); if ($forEnvironment) { - $httpClient = $this->api()->getHttpClient(); + $httpClient = $this->api->getHttpClient(); $domain = EnvironmentDomain::get($this->domainName, $environment->getLink('#domains'), $httpClient); } else { $domain = $project->getDomain($this->domainName); @@ -71,9 +86,8 @@ protected function execute(InputInterface $input, OutputInterface $output) $result = $domain->update(['ssl' => $this->sslOptions]); - if ($this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); + if ($this->activityMonitor->shouldWait($input)) { + $activityMonitor = $this->activityMonitor; $activityMonitor->waitMultiple($result->getActivities(), $project); } diff --git a/src/Command/Environment/EnvironmentActivateCommand.php b/src/Command/Environment/EnvironmentActivateCommand.php index 85636380e6..6aab72954b 100644 --- a/src/Command/Environment/EnvironmentActivateCommand.php +++ b/src/Command/Environment/EnvironmentActivateCommand.php @@ -1,39 +1,65 @@ setName('environment:activate') - ->setDescription('Activate an environment') ->addArgument('environment', InputArgument::IS_ARRAY, 'The environment(s) to activate') ->addOption('parent', null, InputOption::VALUE_REQUIRED, 'Set a new environment parent before activating'); - $this->addResourcesInitOption(['parent', 'default', 'minimum']); - $this->addProjectOption() - ->addEnvironmentOption() - ->addWaitOptions(); + $this->resourcesUtil->addOption($this->getDefinition(), $this->validResourcesInitOptions); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); $this->addExample('Activate the environments "develop" and "stage"', 'develop stage'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->chooseEnvFilter = $this->filterEnvsByStatus(['inactive', 'paused']); - $this->validateInput($input); + $selection = $this->selector->getSelection($input, new SelectorConfig(chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive())); - if ($this->hasSelectedEnvironment()) { - $toActivate = [$this->getSelectedEnvironment()]; + if ($selection->hasEnvironment()) { + $toActivate = [$selection->getEnvironment()]; } else { - $environments = $this->api()->getEnvironments($this->getSelectedProject()); + $environments = $this->api->getEnvironments($selection->getProject()); $environmentIds = $input->getArgument('environment'); $toActivate = array_intersect_key($environments, array_flip($environmentIds)); $notFound = array_diff($environmentIds, array_keys($environments)); @@ -42,66 +68,60 @@ protected function execute(InputInterface $input, OutputInterface $output) } } - $success = $this->activateMultiple($toActivate, $input, $this->stdErr); + $success = $this->activateMultiple($toActivate, $selection->getProject(), $input, $this->stdErr); return $success ? 0 : 1; } /** - * @param Environment[] $environments - * @param InputInterface $input - * @param OutputInterface $output - * - * @return bool + * @param Environment[] $environments */ - protected function activateMultiple(array $environments, InputInterface $input, OutputInterface $output) + protected function activateMultiple(array $environments, Project $project, InputInterface $input, OutputInterface $output): bool { $parentId = $input->getOption('parent'); - if ($parentId && !$this->api()->getEnvironment($parentId, $this->getSelectedProject())) { + if ($parentId && !$this->api->getEnvironment($parentId, $project)) { $this->stdErr->writeln(sprintf('Parent environment not found: %s', $parentId)); return false; } // Validate the --resources-init option. - $resourcesInit = $this->validateResourcesInitInput($input, $this->getSelectedProject()); + $resourcesInit = $this->resourcesUtil->validateInput($input, $project, $this->validResourcesInitOptions); if ($resourcesInit === false) { - return 1; + return false; } $count = count($environments); $processed = 0; // Confirm which environments the user wishes to be activated. $process = []; - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); foreach ($environments as $environment) { if (!$environment->operationAvailable('activate', true)) { if ($environment->isActive()) { - $output->writeln("The environment " . $this->api()->getEnvironmentLabel($environment) . " is already active."); + $output->writeln("The environment " . $this->api->getEnvironmentLabel($environment) . " is already active."); $count--; continue; } if ($environment->status === 'paused') { - $output->writeln("The environment " . $this->api()->getEnvironmentLabel($environment, 'comment') . " is paused."); - if (count($environments) === 1 && $input->isInteractive() && $questionHelper->confirm('Do you want to resume it?')) { - return $this->runOtherCommand('environment:resume', [ + $output->writeln("The environment " . $this->api->getEnvironmentLabel($environment, 'comment') . " is paused."); + if (count($environments) === 1 && $input->isInteractive() && $this->questionHelper->confirm('Do you want to resume it?')) { + return $this->subCommandRunner->run('environment:resume', [ '--project' => $environment->project, '--environment' => $environment->id, '--wait' => $input->getOption('wait'), '--no-wait' => $input->getOption('no-wait'), '--yes' => true, - ]); + ]) === 0; } $output->writeln(sprintf( 'To resume the environment, run: %s environment:resume', - $this->config()->get('application.executable') + $this->config->getStr('application.executable'), )); $count--; continue; } $output->writeln( - "Operation not available: The environment " . $this->api()->getEnvironmentLabel($environment, 'error') . " can't be activated." + "Operation not available: The environment " . $this->api->getEnvironmentLabel($environment, 'error') . " can't be activated.", ); if ($environment->is_main && !$environment->has_code) { $output->writeln(''); @@ -112,8 +132,8 @@ protected function activateMultiple(array $environments, InputInterface $input, } continue; } - $question = "Are you sure you want to activate the environment " . $this->api()->getEnvironmentLabel($environment) . "?"; - if (!$questionHelper->confirm($question)) { + $question = "Are you sure you want to activate the environment " . $this->api->getEnvironmentLabel($environment) . "?"; + if (!$this->questionHelper->confirm($question)) { continue; } $process[$environment->id] = $environment; @@ -132,14 +152,14 @@ protected function activateMultiple(array $environments, InputInterface $input, $output->writeln(sprintf( 'Setting parent of environment %s to %s', $environmentId, - $parentId + $parentId, )); $result = $environment->update(['parent' => $parentId]); $activities = array_merge($activities, $result->getActivities()); } $output->writeln(sprintf( 'Activating environment %s', - $environmentId + $environmentId, )); $activities = array_merge($activities, $environment->runOperation('activate', 'POST', $params)->getActivities()); $processed++; @@ -151,13 +171,12 @@ protected function activateMultiple(array $environments, InputInterface $input, $success = $processed >= $count; if ($processed) { - if ($this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); - $result = $activityMonitor->waitMultiple($activities, $this->getSelectedProject()); + if ($this->activityMonitor->shouldWait($input)) { + $activityMonitor = $this->activityMonitor; + $result = $activityMonitor->waitMultiple($activities, $project); $success = $success && $result; } - $this->api()->clearEnvironmentsCache($this->getSelectedProject()->id); + $this->api->clearEnvironmentsCache($project->id); } return $success; diff --git a/src/Command/Environment/EnvironmentBranchCommand.php b/src/Command/Environment/EnvironmentBranchCommand.php index ef08ae2ae5..f8f585c881 100644 --- a/src/Command/Environment/EnvironmentBranchCommand.php +++ b/src/Command/Environment/EnvironmentBranchCommand.php @@ -1,21 +1,50 @@ setName('environment:branch') - ->setAliases(['branch']) - ->setDescription('Branch an environment') ->addArgument('id', InputArgument::OPTIONAL, 'The ID (branch name) of the new environment') ->addArgument('parent', InputArgument::OPTIONAL, 'The parent of the new environment') ->addOption('title', null, InputOption::VALUE_REQUIRED, 'The title of the new environment') @@ -23,33 +52,37 @@ protected function configure() ->addOption('no-clone-parent', null, InputOption::VALUE_NONE, "Do not clone the parent environment's data") ->addOption('no-checkout', null, InputOption::VALUE_NONE, 'Do not check out the branch locally') ->addHiddenOption('dry-run', null, InputOption::VALUE_NONE, 'Dry run: do not create a new environment'); - $this->addResourcesInitOption(['parent', 'default', 'minimum']); - $this->addProjectOption() - ->addEnvironmentOption() - ->addWaitOptions(); + $this->resourcesUtil->addOption($this->getDefinition(), $this->validResourcesInitOptions); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); $this->addHiddenOption('force', null, InputOption::VALUE_NONE, 'Deprecated option, no longer used'); $this->addHiddenOption('identity-file', 'i', InputOption::VALUE_REQUIRED, 'Deprecated option, no longer used'); $this->addExample('Create a new branch "sprint-2", based on "develop"', 'sprint-2 develop'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->warnAboutDeprecatedOptions(['force', 'identity-file']); + $this->io->warnAboutDeprecatedOptions(['force', 'identity-file']); - $this->envArgName = 'parent'; - $this->chooseEnvText = 'Enter a number to choose a parent environment:'; - $this->enterEnvText = 'Enter the ID of the parent environment'; - $this->chooseEnvFilter = $this->filterEnvsMaybeActive(); $branchName = $input->getArgument('id'); - $this->validateInput($input, $branchName === null); - $selectedProject = $this->getSelectedProject(); + $selectorConfig = new SelectorConfig( + envRequired: $branchName !== null, + envArgName: 'parent', + chooseEnvText: 'Enter a number to choose a parent environment:', + enterEnvText: 'Enter the ID of the parent environment', + chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive(), + ); + $selection = $this->selector->getSelection($input, $selectorConfig); + $selectedProject = $selection->getProject(); if ($branchName === null) { if ($input->isInteractive()) { // List environments. - return $this->runOtherCommand( + return $this->subCommandRunner->run( 'environments', - ['--project' => $selectedProject->id] + ['--project' => $selectedProject->id], ); } $this->stdErr->writeln("You must specify the name of the new branch."); @@ -57,32 +90,30 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - $parentEnvironment = $this->getSelectedEnvironment(); + $parentEnvironment = $selection->getEnvironment(); - if ($branchName === $parentEnvironment->id && ($e = $this->getCurrentEnvironment($selectedProject)) && $e->id === $branchName) { + if ($branchName === $parentEnvironment->id && ($e = $this->selector->getCurrentEnvironment($selectedProject)) && $e->id === $branchName) { $this->stdErr->writeln('Already on ' . $branchName . ''); return 1; } - $projectRoot = $this->getProjectRoot(); + $projectRoot = $this->selector->getProjectRoot(); $dryRun = $input->getOption('dry-run'); $checkoutLocally = $projectRoot && !$input->getOption('no-checkout'); - if ($environment = $this->api()->getEnvironment($branchName, $selectedProject)) { + if ($environment = $this->api->getEnvironment($branchName, $selectedProject)) { if (!$checkoutLocally || $dryRun) { $this->stdErr->writeln("The environment $branchName already exists."); return 1; } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $checkout = $questionHelper->confirm( - "The environment $branchName already exists. Check out?" + $checkout = $this->questionHelper->confirm( + "The environment $branchName already exists. Check out?", ); if ($checkout) { - return $this->runOtherCommand( + return $this->subCommandRunner->run( 'environment:checkout', - ['id' => $environment->id] + ['id' => $environment->id], ); } @@ -91,16 +122,16 @@ protected function execute(InputInterface $input, OutputInterface $output) if (!$parentEnvironment->operationAvailable('branch', true)) { $this->stdErr->writeln( - "Operation not available: The environment " . $this->api()->getEnvironmentLabel($parentEnvironment, 'error', false) . " can't be branched." + "Operation not available: The environment " . $this->api->getEnvironmentLabel($parentEnvironment, 'error', false) . " can't be branched.", ); if ($parentEnvironment->getProperty('has_remote', false) === true - && ($integration = $this->api()->getCodeSourceIntegration($this->getSelectedProject())) + && ($integration = $this->api->getCodeSourceIntegration($selection->getProject())) && $integration->getProperty('prune_branches', false) === true) { $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf("The project's branches are managed externally through its %s integration.", $integration->type)); - if ($this->config()->isCommandEnabled('integration:get')) { - $this->stdErr->writeln(sprintf('To view the integration, run: %s integration:get %s', $this->config()->get('application.executable'), OsUtil::escapeShellArg($integration->id))); + if ($this->config->isCommandEnabled('integration:get')) { + $this->stdErr->writeln(sprintf('To view the integration, run: %s integration:get %s', $this->config->getStr('application.executable'), OsUtil::escapeShellArg($integration->id))); } } elseif ($parentEnvironment->is_dirty) { $this->stdErr->writeln(''); @@ -114,14 +145,14 @@ protected function execute(InputInterface $input, OutputInterface $output) } // Validate the --resources-init option. - $resourcesInit = $this->validateResourcesInitInput($input, $selectedProject); + $resourcesInit = $this->resourcesUtil->validateInput($input, $selectedProject, $this->validResourcesInitOptions); if ($resourcesInit === false) { return 1; } $title = $input->getOption('title') !== null ? $input->getOption('title') : $branchName; - $newLabel = strlen($title) > 0 && $title !== $branchName + $newLabel = strlen((string) $title) > 0 && $title !== $branchName ? '' . $title . ' (' . $branchName . ')' : '' . $branchName . ''; @@ -136,7 +167,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $parentMessage = $input->getOption('no-clone-parent') ? 'Settings will be copied from the parent environment: %s' : 'Settings will be copied and data cloned from the parent environment: %s'; - $this->stdErr->writeln(sprintf($parentMessage, $this->api()->getEnvironmentLabel($parentEnvironment, 'info', false))); + $this->stdErr->writeln(sprintf($parentMessage, $this->api->getEnvironmentLabel($parentEnvironment, 'info', false))); if ($resourcesInit === 'parent') { $this->stdErr->writeln('Resource sizes will be inherited from the parent environment.'); @@ -168,25 +199,23 @@ protected function execute(InputInterface $input, OutputInterface $output) $activities = $result->getActivities(); // Clear the environments cache, as branching has started. - $this->api()->clearEnvironmentsCache($selectedProject->id); + $this->api->clearEnvironmentsCache($selectedProject->id); } - /** @var \Platformsh\Cli\Service\Git $git */ - $git = $this->getService('git'); - $createdNew = false; if ($checkoutLocally) { - if ($git->branchExists($branchName, $projectRoot)) { + /** @var string $projectRoot */ + if ($this->git->branchExists($branchName, $projectRoot)) { $this->stdErr->writeln("Checking out $branchName locally"); - if (!$git->checkOut($branchName, $projectRoot)) { + if (!$this->git->checkOut($branchName, $projectRoot)) { $this->stdErr->writeln('Failed to check out branch locally: ' . $branchName . ''); } } else { // Create a new branch, using the parent if it exists locally. - $parent = $git->branchExists($parentEnvironment->id, $projectRoot) ? $parentEnvironment->id : null; + $parent = $this->git->branchExists($parentEnvironment->id, $projectRoot) ? $parentEnvironment->id : null; $this->stdErr->writeln("Creating local branch $branchName"); - if (!$git->checkOutNew($branchName, $parent, null, $projectRoot)) { + if (!$this->git->checkOutNew($branchName, $parent, null, $projectRoot)) { $this->stdErr->writeln('Failed to create branch locally: ' . $branchName . ''); } $createdNew = true; @@ -194,11 +223,10 @@ protected function execute(InputInterface $input, OutputInterface $output) } $remoteSuccess = true; - if ($this->shouldWait($input) && !$dryRun && $activities) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); + if ($this->activityMonitor->shouldWait($input) && !$dryRun && $activities) { + $activityMonitor = $this->activityMonitor; $remoteSuccess = $activityMonitor->waitMultiple($activities, $selectedProject); - $this->api()->clearEnvironmentsCache($selectedProject->id); + $this->api->clearEnvironmentsCache($selectedProject->id); } // If a new local branch has been created, set its upstream. @@ -208,14 +236,16 @@ protected function execute(InputInterface $input, OutputInterface $output) // project's Git URL. if ($remoteSuccess && $checkoutLocally && $createdNew) { $gitUrl = $selectedProject->getGitUrl(); - $remoteName = $this->config()->get('detection.git_remote_name'); - if ($gitUrl && $git->getConfig(sprintf('remote.%s.url', $remoteName), $projectRoot) === $gitUrl) { + $remoteName = $this->config->getStr('detection.git_remote_name'); + /** @var string $projectRoot */ + if ($gitUrl && $this->git->getConfig(sprintf('remote.%s.url', $remoteName), $projectRoot) === $gitUrl) { $this->stdErr->writeln(sprintf( 'Setting the upstream for the local branch to: %s/%s', - $remoteName, $branchName + $remoteName, + $branchName, )); - if ($git->fetch($remoteName, $branchName, $gitUrl, $projectRoot)) { - $git->setUpstream($remoteName . '/' . $branchName, $branchName, $projectRoot); + if ($this->git->fetch($remoteName, $branchName, $gitUrl, $projectRoot)) { + $this->git->setUpstream($remoteName . '/' . $branchName, $branchName, $projectRoot); } } } diff --git a/src/Command/Environment/EnvironmentCheckoutCommand.php b/src/Command/Environment/EnvironmentCheckoutCommand.php index d6c7ecea20..3000ba1407 100644 --- a/src/Command/Environment/EnvironmentCheckoutCommand.php +++ b/src/Command/Environment/EnvironmentCheckoutCommand.php @@ -1,41 +1,59 @@ setName('environment:checkout') - ->setAliases(['checkout']) - ->setDescription('Check out an environment') - ->addArgument( - 'id', - InputArgument::OPTIONAL, - 'The ID of the environment to check out. For example: "sprint2"' - ); + $this->addArgument( + 'environment', + InputArgument::OPTIONAL, + 'The ID of the environment to check out. For example: "sprint2"', + ); + $this->addCompleter($this->selector); Ssh::configureInput($this->getDefinition()); $this->addExample('Check out the environment "develop"', 'develop'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $project = $this->getCurrentProject(); - $projectRoot = $this->getProjectRoot(); + $project = $this->selector->getCurrentProject(); + $projectRoot = $this->selector->getProjectRoot(); if (!$project || !$projectRoot) { throw new RootNotFoundException(); } - $branch = $input->getArgument('id'); + $branch = $input->getArgument('environment'); if ($branch === null) { if ($input->isInteractive()) { $branch = $this->offerBranchChoice($project, $projectRoot); @@ -48,13 +66,10 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } } + $this->git->setDefaultRepositoryDir($projectRoot); - /** @var \Platformsh\Cli\Service\Git $git */ - $git = $this->getService('git'); - $git->setDefaultRepositoryDir($projectRoot); - - $existsLocally = $git->branchExists($branch); - if (!$existsLocally && !$this->api()->getEnvironment($branch, $project)) { + $existsLocally = $this->git->branchExists($branch); + if (!$existsLocally && !$this->api->getEnvironment($branch, $project)) { $this->stdErr->writeln('Branch not found: ' . $branch . ''); return 1; @@ -64,31 +79,30 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($existsLocally) { $this->stdErr->writeln('Checking out ' . $branch . ''); - return $git->checkOut($branch) ? 0 : 1; + return $this->git->checkOut($branch) ? 0 : 1; } // Make sure that remotes are set up correctly. - /** @var \Platformsh\Cli\Local\LocalProject $localProject */ - $localProject = $this->getService('local.project'); + $localProject = $this->localProject; $localProject->ensureGitRemote($projectRoot, $project->getGitUrl()); // Determine the correct upstream for the new branch. If there is an // 'origin' remote, then it has priority. - $upstreamRemote = $this->config()->get('detection.git_remote_name'); - $originRemoteUrl = $git->getConfig('remote.origin.url'); - if ($originRemoteUrl !== $project->getGitUrl() && $git->remoteBranchExists('origin', $branch)) { + $upstreamRemote = $this->config->getStr('detection.git_remote_name'); + $originRemoteUrl = $this->git->getConfig('remote.origin.url'); + if ($originRemoteUrl !== $project->getGitUrl() && $this->git->remoteBranchExists('origin', $branch)) { $upstreamRemote = 'origin'; } // Fetch the branch from the upstream remote. - $git->fetch($upstreamRemote, $branch, $originRemoteUrl); + $this->git->fetch($upstreamRemote, $branch, $originRemoteUrl ?: ''); $upstream = $upstreamRemote . '/' . $branch; $this->stdErr->writeln(sprintf('Creating local branch %s based on upstream %s', $branch, $upstream)); // Create the new branch, and set the correct upstream. - $success = $git->checkOutNew($branch, null, $upstream); + $success = $this->git->checkOutNew($branch, null, $upstream); return $success ? 0 : 1; } @@ -102,12 +116,12 @@ protected function execute(InputInterface $input, OutputInterface $output) * @return string|false * The branch name, or false on failure. */ - protected function offerBranchChoice(Project $project, $projectRoot) + protected function offerBranchChoice(Project $project, string $projectRoot): string|false { - $environments = $this->api()->getEnvironments($project); - $currentEnvironment = $this->getCurrentEnvironment($project); + $environments = $this->api->getEnvironments($project); + $currentEnvironment = $this->selector->getCurrentEnvironment($project); if ($currentEnvironment) { - $this->stdErr->writeln("The current environment is " . $this->api()->getEnvironmentLabel($currentEnvironment) . "."); + $this->stdErr->writeln("The current environment is " . $this->api->getEnvironmentLabel($currentEnvironment) . "."); $this->stdErr->writeln(''); } $environmentList = []; @@ -118,11 +132,9 @@ protected function offerBranchChoice(Project $project, $projectRoot) if ($currentEnvironment && (string) $id === $currentEnvironment->id) { continue; } - $environmentList[$id] = $this->api()->getEnvironmentLabel($environment, false); + $environmentList[$id] = $this->api->getEnvironmentLabel($environment, false); } - /** @var \Platformsh\Cli\Local\LocalProject $localProject */ - $localProject = $this->getService('local.project'); - $projectConfig = $localProject->getProjectConfig($projectRoot); + $projectConfig = $this->localProject->getProjectConfig($projectRoot); if (!empty($projectConfig['mapping'])) { foreach ($projectConfig['mapping'] as $branch => $id) { if (isset($environmentList[$id]) && isset($environmentList[$branch])) { @@ -134,15 +146,12 @@ protected function offerBranchChoice(Project $project, $projectRoot) if (!count($environmentList)) { $this->stdErr->writeln(sprintf( 'To create a new environment, run: %s branch [new-branch]', - $this->config()->get('application.executable') + $this->config->getStr('application.executable'), )); return false; } - /** @var \Platformsh\Cli\Service\QuestionHelper $helper */ - $helper = $this->getService('question_helper'); - // If there's more than one choice, present the user with a list. if (count($environmentList) > 1) { $chooseEnvironmentText = "Enter a number to check out another environment:"; @@ -150,15 +159,15 @@ protected function offerBranchChoice(Project $project, $projectRoot) // The environment ID will be an integer if it was numeric // (because PHP does that with array keys), so it's cast back to // a string here. - return (string) $helper->choose($environmentList, $chooseEnvironmentText); + return $this->questionHelper->choose($environmentList, $chooseEnvironmentText); } // If there's only one choice, QuestionHelper::choose() does not // interact. But we still need interactive confirmation at this point. $environmentId = key($environmentList); if ($environmentId !== false) { - $label = $this->api()->getEnvironmentLabel($environments[$environmentId]); - if ($helper->confirm(sprintf('Check out environment %s?', $label))) { + $label = $this->api->getEnvironmentLabel($environments[$environmentId]); + if ($this->questionHelper->confirm(sprintf('Check out environment %s?', $label))) { return $environmentId; } } diff --git a/src/Command/Environment/EnvironmentCurlCommand.php b/src/Command/Environment/EnvironmentCurlCommand.php index 88da71fd5e..e01cc725fb 100644 --- a/src/Command/Environment/EnvironmentCurlCommand.php +++ b/src/Command/Environment/EnvironmentCurlCommand.php @@ -1,39 +1,41 @@ setName('environment:curl') - ->setDescription("Run an authenticated cURL request on an environment's API"); + parent::__construct(); + } + protected function configure(): void + { CurlCli::configureInput($this->getDefinition()); - $this->addProjectOption(); - $this->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input, false, true); - - // Initialize the API service so that it gets CommandBase's event listeners - // (allowing for auto login). - $this->api(); - - $url = $this->getSelectedEnvironment()->getUri(); - - /** @var CurlCli $curl */ - $curl = $this->getService('curl_cli'); + $selection = $this->selector->getSelection($input, new SelectorConfig(selectDefaultEnv: true)); + $url = $selection->getEnvironment()->getUri(); - return $curl->run($url, $input, $output); + return $this->curlCli->run($url, $input, $output); } } diff --git a/src/Command/Environment/EnvironmentDeleteCommand.php b/src/Command/Environment/EnvironmentDeleteCommand.php index 31ef8c7e29..e4a054ce8d 100644 --- a/src/Command/Environment/EnvironmentDeleteCommand.php +++ b/src/Command/Environment/EnvironmentDeleteCommand.php @@ -1,24 +1,45 @@ setName('environment:delete') ->setHiddenAliases(['environment:deactivate']) - ->setDescription('Delete one or more environments') ->addArgument('environment', InputArgument::IS_ARRAY, "The environment(s) to delete.\n" . Wildcard::HELP . "\n" . ArrayArgument::SPLIT_HELP) ->addOption('delete-branch', null, InputOption::VALUE_NONE, 'Delete Git branch(es) for inactive environments, without confirmation') ->addOption('no-delete-branch', null, InputOption::VALUE_NONE, 'Do not delete any Git branch(es) (inactive environments)') @@ -32,34 +53,36 @@ protected function configure() ->addOption('exclude-status', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Environment status(es) of which not to delete' . "\n" . ArrayArgument::SPLIT_HELP) ->addOption('merged', null, InputOption::VALUE_NONE, 'Delete all merged environments (adding to any others selected)') ->addOption('allow-delete-parent', null, InputOption::VALUE_NONE, 'Allow environments that have children to be deleted'); - $this->addProjectOption() - ->addEnvironmentOption() - ->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); $this->addExample('Delete the currently checked out environment'); $this->addExample('Delete the environments "test" and "example-1"', 'test example-1'); $this->addExample('Delete all inactive environments', '--inactive'); $this->addExample('Delete all environments merged with their parent', '--merged'); - $service = $this->config()->get('service.name'); - $this->setHelp(<<config->getStr('service.name'); + $this->setHelp( + <<setArgument('environment', null); $inputCopy->setOption('environment', null); - $this->validateInput($inputCopy, true); + $selection = $this->selector->getSelection($input, new SelectorConfig(envRequired: false)); - $environments = $this->api()->getEnvironments($this->getSelectedProject()); + $environments = $this->api->getEnvironments($selection->getProject()); /** * A list of selected environments, keyed by ID to avoid duplication. @@ -77,12 +100,12 @@ protected function execute(InputInterface $input, OutputInterface $output) } if ($specifiedEnvironmentIds) { $anythingSpecified = true; - $allIds = \array_map(function (Environment $e) { return $e->id; }, $environments); + $allIds = \array_map(fn(Environment $e) => $e->id, $environments); $specifiedEnvironmentIds = Wildcard::select($allIds, $specifiedEnvironmentIds); $notFound = array_diff($specifiedEnvironmentIds, array_keys($environments)); if (!empty($notFound)) { // Refresh the environments list if any environment is not found. - $environments = $this->api()->getEnvironments($this->getSelectedProject(), true); + $environments = $this->api->getEnvironments($selection->getProject(), true); $notFound = array_diff($specifiedEnvironmentIds, array_keys($environments)); } foreach ($notFound as $notFoundId) { @@ -107,10 +130,9 @@ protected function execute(InputInterface $input, OutputInterface $output) } $inactive = array_filter( $environments, - function ($environment) { + fn($environment): bool => /** @var Environment $environment */ - return $environment->status == 'inactive'; - } + $environment->status == 'inactive', ); $this->stdErr->writeln($this->formatPlural(count($inactive), 'inactive environment') . ' found.'); $this->stdErr->writeln(''); @@ -162,8 +184,8 @@ function ($environment) { // Add the current environment if nothing is otherwise specified. if (!$anythingSpecified && empty($selectedEnvironments) - && ($current = $this->getCurrentEnvironment($this->getSelectedProject()))) { - $this->stdErr->writeln('Nothing specified; selecting the current environment: '. $this->api()->getEnvironmentLabel($current)); + && ($current = $this->selector->getCurrentEnvironment($selection->getProject()))) { + $this->stdErr->writeln('Nothing specified; selecting the current environment: ' . $this->api->getEnvironmentLabel($current)); $this->stdErr->writeln(''); $selectedEnvironments[$current->id] = $current; } @@ -171,7 +193,7 @@ function ($environment) { // Exclude environment type(s) specified via --exclude-type or --only-type. $excludeTypes = ArrayArgument::getOption($input, 'exclude-type'); $onlyTypes = ArrayArgument::getOption($input, 'only-type'); - $filtered = \array_filter($selectedEnvironments, function (Environment $environment) use ($excludeTypes, $onlyTypes) { + $filtered = \array_filter($selectedEnvironments, function (Environment $environment) use ($excludeTypes, $onlyTypes): bool { if (\in_array($environment->type, $excludeTypes, true)) { return false; } @@ -189,7 +211,7 @@ function ($environment) { // Exclude environment status(es) specified via --exclude-status or --only-status. $excludeStatuses = ArrayArgument::getOption($input, 'exclude-status'); $onlyStatuses = ArrayArgument::getOption($input, 'only-status'); - $filtered = \array_filter($selectedEnvironments, function (Environment $environment) use ($excludeStatuses, $onlyStatuses) { + $filtered = \array_filter($selectedEnvironments, function (Environment $environment) use ($excludeStatuses, $onlyStatuses): bool { if (\in_array($environment->status, $excludeStatuses, true)) { return false; } @@ -217,7 +239,7 @@ function ($environment) { // Exclude environments which have children. if (!$input->getOption('allow-delete-parent')) { - $filtered = \array_filter($selectedEnvironments, function (Environment $environment) use ($environments) { + $filtered = \array_filter($selectedEnvironments, function (Environment $environment) use ($environments): bool { foreach ($environments as $potentialChild) { if ($potentialChild->parent === $environment->id) { return false; @@ -248,12 +270,10 @@ function ($environment) { } // Confirm which of the environments the user wishes to be deleted. - ksort($selectedEnvironments, SORT_NATURAL|SORT_FLAG_CASE); - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); + ksort($selectedEnvironments, SORT_NATURAL | SORT_FLAG_CASE); $toDeleteBranch = []; $toDeactivate = []; - $shouldWait = $this->shouldWait($input); + $shouldWait = $this->activityMonitor->shouldWait($input); $byStatus = ['deleting' => [], 'dirty' => [], 'active or paused' => [], 'inactive' => []]; foreach ($selectedEnvironments as $key => $environment) { @@ -265,8 +285,8 @@ function ($environment) { } $codeSourceIntegration = null; - if ($this->hasExternalGitHost($this->getSelectedProject())) { - $codeSourceIntegration = $this->api()->getCodeSourceIntegration($this->getSelectedProject()); + if ($this->projectSshInfo->hasExternalGitHost($selection->getProject())) { + $codeSourceIntegration = $this->api->getCodeSourceIntegration($selection->getProject()); } $integrationPrunesBranches = $codeSourceIntegration && $codeSourceIntegration->getProperty('prune_branches', false); @@ -279,7 +299,7 @@ function ($environment) { switch ($status) { case 'dirty': if ($isSingle) { - $this->stdErr->writeln(sprintf("The environment %s has in-progress activity, and therefore can't be deleted yet.", $this->api()->getEnvironmentLabel(reset($environments), 'error'))); + $this->stdErr->writeln(sprintf("The environment %s has in-progress activity, and therefore can't be deleted yet.", $this->api->getEnvironmentLabel(reset($environments), 'error'))); } elseif ($isSubSet) { $this->stdErr->writeln("The following environments have in-progress activity, and therefore can't be deleted yet: " . $this->listEnvironments($environments, 'error')); } else { @@ -290,7 +310,7 @@ function ($environment) { break; case 'deleting': if ($isSingle) { - $this->stdErr->writeln(sprintf('The environment %s is already being deleted.', $this->api()->getEnvironmentLabel(reset($environments), 'error'))); + $this->stdErr->writeln(sprintf('The environment %s is already being deleted.', $this->api->getEnvironmentLabel(reset($environments), 'error'))); } elseif ($isSubSet) { $this->stdErr->writeln('The following environments are already being deleted: ' . $this->listEnvironments($environments, 'error')); } else { @@ -302,7 +322,7 @@ function ($environment) { $confirmText = 'Are you sure you want to delete them?'; $deleteConfirmText = 'Delete the inactive environments (Git branches) too?'; if ($isSingle) { - $this->stdErr->writeln(sprintf('The environment %s is currently active.', $this->api()->getEnvironmentLabel(reset($environments), 'comment'))); + $this->stdErr->writeln(sprintf('The environment %s is currently active.', $this->api->getEnvironmentLabel(reset($environments), 'comment'))); $this->stdErr->writeln('Deleting it will delete all associated data.'); $confirmText = 'Are you sure you want to delete this environment?'; $deleteConfirmText = 'Delete the inactive environment (Git branch) too?'; @@ -312,7 +332,7 @@ function ($environment) { } else { $this->stdErr->writeln('The environments are currently active. Deleting them will delete all associated data.'); } - if ($questionHelper->confirm($confirmText)) { + if ($this->questionHelper->confirm($confirmText)) { $toDeactivate += $environments; if ($input->getOption('delete-branch')) { if (!$shouldWait) { @@ -325,7 +345,7 @@ function ($environment) { } else { $toDeleteBranch += $environments; } - } elseif ($shouldWait && $input->isInteractive() && !$input->getOption('no-delete-branch') && !$integrationPrunesBranches && $questionHelper->confirm($deleteConfirmText)) { + } elseif ($shouldWait && $input->isInteractive() && !$input->getOption('no-delete-branch') && !$integrationPrunesBranches && $this->questionHelper->confirm($deleteConfirmText)) { $toDeleteBranch += $environments; } } else { @@ -336,7 +356,7 @@ function ($environment) { case 'inactive': if ($input->getOption('no-delete-branch')) { if ($isSingle) { - $this->stdErr->writeln(sprintf('The environment %s is inactive and --no-delete-branch was specified, so it will not be deleted.', $this->api()->getEnvironmentLabel(reset($environments), 'comment'))); + $this->stdErr->writeln(sprintf('The environment %s is inactive and --no-delete-branch was specified, so it will not be deleted.', $this->api->getEnvironmentLabel(reset($environments), 'comment'))); } elseif ($isSubSet) { $this->stdErr->writeln('The following environment(s) are inactive and --no-delete-branch was specified, so they will not be deleted: ' . $this->listEnvironments($environments, 'comment')); } else { @@ -352,14 +372,14 @@ function ($environment) { break; } if ($isSingle) { - $message = sprintf('Are you sure you want to delete the inactive environment %s?', $this->api()->getEnvironmentLabel(reset($environments), 'comment')); + $message = sprintf('Are you sure you want to delete the inactive environment %s?', $this->api->getEnvironmentLabel(reset($environments), 'comment')); } elseif ($isSubSet) { $message = 'The following environment(s) are inactive: ' . $this->listEnvironments($environments, 'comment') . "\nAre you sure you want to delete them?"; } else { $message = sprintf('Are you sure you want to delete %d inactive environment(s)?', count($environments)); } - if ($input->getOption('delete-branch') || $questionHelper->confirm($message)) { + if ($input->getOption('delete-branch') || $this->questionHelper->confirm($message)) { $toDeleteBranch += $environments; } else { $error = true; @@ -381,13 +401,13 @@ function ($environment) { if (empty($toDeleteBranch) && empty($toDeactivate)) { $this->stdErr->writeln('No environments to delete.'); if (!$anythingSpecified) { - $this->stdErr->writeln(\sprintf('For help, run: %s help environment:delete', $this->config()->get('application.executable'))); + $this->stdErr->writeln(\sprintf('For help, run: %s help environment:delete', $this->config->getStr('application.executable'))); } return $error ? 1 : 0; } - $success = $this->deleteMultiple($toDeactivate, $toDeleteBranch, $input) && !$error; + $success = $this->deleteMultiple($toDeactivate, $toDeleteBranch, $selection->getProject(), $input) && !$error; return $success ? 0 : 1; } @@ -398,26 +418,22 @@ function ($environment) { * * @return string */ - private function listEnvironments(array $environments, $tag = 'info') + private function listEnvironments(array $environments, string $tag = 'info'): string { - $uniqueIds = \array_unique(\array_map(function(Environment $e) { return $e->id; }, $environments)); + $uniqueIds = \array_unique(\array_map(fn(Environment $e) => $e->id, $environments)); natcasesort($uniqueIds); return "<$tag>" . implode(", <$tag>", $uniqueIds) . ""; } /** - * @param array $toDeactivate - * @param array $toDeleteBranch - * @param InputInterface $input - * - * @return bool + * @param Environment[] $toDeactivate + * @param Environment[] $toDeleteBranch */ - protected function deleteMultiple(array $toDeactivate, array $toDeleteBranch, InputInterface $input) + protected function deleteMultiple(array $toDeactivate, array $toDeleteBranch, Project $project, InputInterface $input): bool { $error = false; $deactivateActivities = []; $deactivated = 0; - /** @var Environment $environment */ foreach ($toDeactivate as $environmentId => $environment) { try { $this->stdErr->writeln("Deleting environment $environmentId"); @@ -428,10 +444,9 @@ protected function deleteMultiple(array $toDeactivate, array $toDeleteBranch, In } } - if ($this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); - if (!$activityMonitor->waitMultiple($deactivateActivities, $this->getSelectedProject())) { + if ($this->activityMonitor->shouldWait($input)) { + $activityMonitor = $this->activityMonitor; + if (!$activityMonitor->waitMultiple($deactivateActivities, $project)) { $error = true; } } @@ -467,7 +482,7 @@ protected function deleteMultiple(array $toDeactivate, array $toDeleteBranch, In } if (($deleted || $deactivated || $error) && isset($environment)) { - $this->api()->clearEnvironmentsCache($environment->project); + $this->api->clearEnvironmentsCache($environment->project); } return !$error; @@ -475,13 +490,8 @@ protected function deleteMultiple(array $toDeactivate, array $toDeleteBranch, In /** * Formats a string with a singular or plural count. - * - * @param int $count - * @param string $singular - * @param string|null $plural - * @return string */ - private function formatPlural($count, $singular, $plural = null) + private function formatPlural(int $count, string $singular, ?string $plural = null): string { if ($count === 1) { $name = $singular; diff --git a/src/Command/Environment/EnvironmentDrushCommand.php b/src/Command/Environment/EnvironmentDrushCommand.php index 62b208b2f4..f93b24d7b2 100644 --- a/src/Command/Environment/EnvironmentDrushCommand.php +++ b/src/Command/Environment/EnvironmentDrushCommand.php @@ -1,29 +1,40 @@ setName('environment:drush') - ->setAliases(['drush']) - ->setDescription('Run a drush command on the remote environment') ->addArgument('cmd', InputArgument::OPTIONAL | InputArgument::IS_ARRAY, 'A command to pass to Drush', ['status']); - $this->addProjectOption() - ->addEnvironmentOption() - ->addAppOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->selector->addAppOption($this->getDefinition()); + $this->addCompleter($this->selector); Ssh::configureInput($this->getDefinition()); $this->addExample('Run "drush status" on the remote environment', 'status'); $this->addExample('Enable the Overlay module on the remote environment', 'en overlay'); @@ -31,7 +42,7 @@ protected function configure() $this->addExample('Alternative syntax (quoting the whole command)', "'user-login --mail=name@example.com'"); } - public function isHidden() + public function isHidden(): bool { if (parent::isHidden()) { return true; @@ -39,33 +50,33 @@ public function isHidden() // Hide this command in the list if the project is not Drupal. // Avoid checking if running in the home directory. - $projectRoot = $this->getProjectRoot(); - if ($projectRoot && $this->config()->getHomeDirectory() !== getcwd() && !Drupal::isDrupal($projectRoot)) { + $projectRoot = $this->selector->getProjectRoot(); + if ($projectRoot && $this->config->getHomeDirectory() !== getcwd() && !Drupal::isDrupal($projectRoot)) { return true; } return false; } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); + $selection = $this->selector->getSelection($input); $drushCommand = (array) $input->getArgument('cmd'); if (count($drushCommand) === 1) { $drushCommand = reset($drushCommand); } else { - $drushCommand = implode(' ', array_map([OsUtil::class, 'escapePosixShellArg'], $drushCommand)); + $drushCommand = implode(' ', array_map(OsUtil::escapePosixShellArg(...), $drushCommand)); } // Pass through options that the CLI shares with Drush. foreach (['yes', 'no', 'quiet'] as $option) { - if ($input->getOption($option) && !preg_match('/\b' . preg_quote($option) . '\b/', $drushCommand)) { + if ($input->getOption($option) && !preg_match('/\b' . preg_quote($option) . '\b/', (string) $drushCommand)) { $drushCommand .= " --$option"; } } - if (!preg_match('/\b((verbose|debug|quiet)\b|-v)/', $drushCommand)) { - if ($output->getVerbosity() >= OutputInterface::VERBOSITY_DEBUG ) { + if (!preg_match('/\b((verbose|debug|quiet)\b|-v)/', (string) $drushCommand)) { + if ($output->getVerbosity() >= OutputInterface::VERBOSITY_DEBUG) { $drushCommand .= " --debug"; } elseif ($output->getVerbosity() >= OutputInterface::VERBOSITY_VERY_VERBOSE) { $drushCommand .= " --verbose"; @@ -76,25 +87,20 @@ protected function execute(InputInterface $input, OutputInterface $output) } } - $appContainer = $this->selectRemoteContainer($input, false); - $host = $this->selectHost($input, false, $appContainer); + $appContainer = $selection->getRemoteContainer(); + $host = $this->selector->getHostFromSelection($input, $selection); $appName = $appContainer->getName(); - $selectedEnvironment = $this->getSelectedEnvironment(); - - $deployment = $this->api()->getCurrentDeployment($selectedEnvironment); + $selectedEnvironment = $selection->getEnvironment(); - // Use the PLATFORM_DOCUMENT_ROOT environment variable, if set, to - // determine the path to Drupal. - /** @var \Platformsh\Cli\Service\RemoteEnvVars $envVarsService */ - $envVarsService = $this->getService('remote_env_vars'); - $documentRoot = $envVarsService->getEnvVar('DOCUMENT_ROOT', $host); + $deployment = $this->api->getCurrentDeployment($selectedEnvironment); + $documentRoot = $this->remoteEnvVars->getEnvVar('DOCUMENT_ROOT', $host); if ($documentRoot !== '') { $drupalRoot = $documentRoot; } else { // Fall back to a combination of the document root (from the // deployment configuration) and the PLATFORM_APP_DIR variable. - $appDir = $envVarsService->getEnvVar('APP_DIR', $host) ?: '/app'; + $appDir = $this->remoteEnvVars->getEnvVar('APP_DIR', $host) ?: '/app'; $remoteApp = $deployment->getWebApp($appName); $relativeDocRoot = AppConfig::fromWebApp($remoteApp)->getDocumentRoot(); @@ -105,7 +111,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $columns = (new Terminal())->getWidth(); $sshDrushCommand = "COLUMNS=$columns drush --root=" . OsUtil::escapePosixShellArg($drupalRoot); - if ($siteUrl = $this->api()->getSiteUrl($selectedEnvironment, $appName, $deployment)) { + if ($siteUrl = $this->api->getSiteUrl($selectedEnvironment, $appName, $deployment)) { $sshDrushCommand .= " --uri=" . OsUtil::escapePosixShellArg($siteUrl); } $sshDrushCommand .= ' ' . $drushCommand; diff --git a/src/Command/Environment/EnvironmentHttpAccessCommand.php b/src/Command/Environment/EnvironmentHttpAccessCommand.php index 7077e25d86..3795b351b2 100644 --- a/src/Command/Environment/EnvironmentHttpAccessCommand.php +++ b/src/Command/Environment/EnvironmentHttpAccessCommand.php @@ -1,43 +1,54 @@ setName('environment:http-access') - ->setAliases(['httpaccess']) - ->setDescription('Update HTTP access settings for an environment') ->addOption( 'access', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, - 'Access restriction in the format "permission:address". Use 0 to clear all addresses.' + 'Access restriction in the format "permission:address". Use 0 to clear all addresses.', ) ->addOption( 'auth', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, - 'HTTP Basic auth credentials in the format "username:password". Use 0 to clear all credentials.' + 'HTTP Basic auth credentials in the format "username:password". Use 0 to clear all credentials.', ) ->addOption( 'enabled', null, InputOption::VALUE_REQUIRED, - 'Whether access control should be enabled: 1 to enable, 0 to disable' + 'Whether access control should be enabled: 1 to enable, 0 to disable', ); - $this->addProjectOption() - ->addEnvironmentOption() - ->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); $this->addExample('Require a username and password', '--auth myname:mypassword'); $this->addExample('Restrict access to only one IP address', '--access allow:69.208.1.192 --access deny:any'); $this->addExample('Remove the password requirement, keeping IP restrictions', '--auth 0'); @@ -45,13 +56,11 @@ protected function configure() } /** - * @param $auth - * * @throws InvalidArgumentException * - * @return array + * @return array{username: string, password: string} */ - protected function parseAuth($auth) + protected function parseAuth(string $auth): array { $parts = explode(':', $auth, 2); if (count($parts) != 2) { @@ -74,19 +83,17 @@ protected function parseAuth($auth) } /** - * @param $access - * * @throws InvalidArgumentException * - * @return array + * @return array{address: string, permission: string} */ - protected function parseAccess($access) + protected function parseAccess(string $access): array { $parts = explode(':', $access, 2); if (count($parts) != 2) { $message = sprintf( 'Access "%s" is not valid, please use the format: permission:address', - $access + $access, ); throw new InvalidArgumentException($message); } @@ -94,12 +101,12 @@ protected function parseAccess($access) if (!in_array($parts[0], ['allow', 'deny'])) { $message = sprintf( "The permission type '%s' is not valid; it must be one of 'allow' or 'deny'", - $parts[0] + $parts[0], ); throw new InvalidArgumentException($message); } - list($permission, $address) = $parts; + [$permission, $address] = $parts; $this->validateAddress($address); @@ -123,23 +130,23 @@ protected function parseAccess($access) } /** - * @param string $address + * Validates an IP address. * * @throws InvalidArgumentException */ - protected function validateAddress($address) + protected function validateAddress(string $address): void { if ($address == 'any') { return; } $extractIp = preg_match('#^([^/]+)(/([0-9]{1,3}))?$#', $address, $matches); $is_valid_ip = $extractIp && filter_var($matches[1], FILTER_VALIDATE_IP); - $is_valid_ipv4 = $is_valid_ip && filter_var($matches[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4); - $is_valid_ipv6 = $is_valid_ip && filter_var($matches[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); if (!$extractIp || !$is_valid_ip) { $message = sprintf('The address "%s" is not a valid IP address or CIDR', $address); throw new InvalidArgumentException($message); } + $is_valid_ipv4 = filter_var($matches[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4); + $is_valid_ipv6 = filter_var($matches[1], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); if ($is_valid_ipv4 && isset($matches[3]) && $matches[3] > 32) { $message = sprintf('The address "%s" is not a valid IPv4 address or CIDR', $address); throw new InvalidArgumentException($message); @@ -150,9 +157,9 @@ protected function validateAddress($address) } } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); + $selection = $this->selector->getSelection($input); $auth = $input->getOption('auth'); $access = $input->getOption('access'); @@ -188,35 +195,31 @@ protected function execute(InputInterface $input, OutputInterface $output) $change = true; } - $selectedEnvironment = $this->getSelectedEnvironment(); + $selectedEnvironment = $selection->getEnvironment(); $environmentId = $selectedEnvironment->id; - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - // Patch the environment with the changes. if ($change) { $result = $selectedEnvironment->update(['http_access' => $accessOpts]); - $this->api()->clearEnvironmentsCache($selectedEnvironment->project); + $this->api->clearEnvironmentsCache($selectedEnvironment->project); $this->stdErr->writeln("Updated HTTP access settings for the environment $environmentId:"); - $output->writeln($formatter->format($selectedEnvironment->http_access, 'http_access')); + $output->writeln($this->propertyFormatter->format($selectedEnvironment->http_access, 'http_access')); $success = true; if (!$result->countActivities()) { - $this->redeployWarning(); - } elseif ($this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); - $success = $activityMonitor->waitMultiple($result->getActivities(), $this->getSelectedProject()); + $this->api->redeployWarning(); + } elseif ($this->activityMonitor->shouldWait($input)) { + $activityMonitor = $this->activityMonitor; + $success = $activityMonitor->waitMultiple($result->getActivities(), $selection->getProject()); } return $success ? 0 : 1; } $this->stdErr->writeln("HTTP access settings for the environment $environmentId:"); - $output->writeln($formatter->format($selectedEnvironment->http_access, 'http_access')); + $output->writeln($this->propertyFormatter->format($selectedEnvironment->http_access, 'http_access')); return 0; } diff --git a/src/Command/Environment/EnvironmentInfoCommand.php b/src/Command/Environment/EnvironmentInfoCommand.php index a7b2687ff2..bfa30b8cf9 100644 --- a/src/Command/Environment/EnvironmentInfoCommand.php +++ b/src/Command/Environment/EnvironmentInfoCommand.php @@ -1,38 +1,45 @@ setName('environment:info') ->addArgument('property', InputArgument::OPTIONAL, 'The name of the property') ->addArgument('value', InputArgument::OPTIONAL, 'Set a new value for the property') - ->addOption('refresh', null, InputOption::VALUE_NONE, 'Whether to refresh the cache') - ->setDescription('Read or set properties for an environment'); + ->addOption('refresh', null, InputOption::VALUE_NONE, 'Whether to refresh the cache'); PropertyFormatter::configureInput($this->getDefinition()); Table::configureInput($this->getDefinition()); - $this->addProjectOption() - ->addEnvironmentOption() - ->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); $this->addExample('Read all environment properties') ->addExample("Show the environment's status", 'status') ->addExample('Show the date the environment was created', 'created_at') @@ -43,38 +50,32 @@ protected function configure() $this->setHiddenAliases(['environment:metadata']); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); + $selection = $this->selector->getSelection($input); - $environment = $this->getSelectedEnvironment(); + $environment = $selection->getEnvironment(); if ($input->getOption('refresh')) { $environment->refresh(); } $property = $input->getArgument('property'); - $this->formatter = $this->getService('property_formatter'); - if (!$property) { return $this->listProperties($environment); } $value = $input->getArgument('value'); if ($value !== null) { - return $this->setProperty($property, $value, $environment, !$this->shouldWait($input)); + return $this->setProperty($property, $value, $environment, $selection->getProject(), !$this->activityMonitor->shouldWait($input)); } - switch ($property) { - case 'url': - $value = $environment->getUri(true); - break; - - default: - $value = $this->api()->getNestedProperty($environment, $property); - } + $value = match ($property) { + 'url' => $environment->getUri(true), + default => $this->api->getNestedProperty($environment, $property), + }; - $output->writeln($this->formatter->format($value, $property)); + $output->writeln($this->propertyFormatter->format($value, $property)); return 0; } @@ -84,37 +85,30 @@ protected function execute(InputInterface $input, OutputInterface $output) * * @return int */ - protected function listProperties(Environment $environment) + protected function listProperties(Environment $environment): int { $headings = []; $values = []; foreach ($environment->getProperties() as $key => $value) { $headings[] = new AdaptiveTableCell($key, ['wrap' => false]); - $values[] = $this->formatter->format($value, $key); + $values[] = $this->propertyFormatter->format($value, $key); } - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - $table->renderSimple($values, $headings); + $this->table->renderSimple($values, $headings); return 0; } - /** - * @param string $property - * @param string $value - * @param Environment $environment - * @param bool $noWait - * - * @return int - */ - protected function setProperty($property, $value, Environment $environment, $noWait) + protected function setProperty(string $property, string $value, Environment $environment, Project $project, bool $noWait): int { - if (!$this->validateValue($property, $value)) { + if (!$this->validateValue($property, $value, $environment, $project)) { return 1; } // @todo refactor normalizing the value according to the property (this is a mess) $type = $this->getType($property); + if (!$type) { + return 1; + } if ($type === 'boolean' && $value === 'false') { $value = false; } @@ -129,7 +123,7 @@ protected function setProperty($property, $value, Environment $environment, $noW $this->stdErr->writeln(sprintf( 'Property %s already set as: %s', $property, - $this->formatter->format($environment->getProperty($property, false), $property) + $this->propertyFormatter->format($environment->getProperty($property, false), $property), )); return 0; @@ -138,7 +132,7 @@ protected function setProperty($property, $value, Environment $environment, $noW $result = $environment->update([$property => $value]); } catch (BadResponseException $e) { // Translate validation error messages. - if (($response = $e->getResponse()) && $response->getStatusCode() === 400 && ($body = $response->getBody())) { + if ($e->getResponse()->getStatusCode() === 400 && ($body = $e->getResponse()->getBody())) { $detail = \json_decode((string) $body, true); if (\is_array($detail) && !empty($detail['detail'][$property])) { $this->stdErr->writeln("Invalid value for $property: " . $detail['detail'][$property]); @@ -150,19 +144,18 @@ protected function setProperty($property, $value, Environment $environment, $noW $this->stdErr->writeln(sprintf( 'Property %s set to: %s', $property, - $this->formatter->format($environment->$property, $property) + $this->propertyFormatter->format($environment->$property, $property), )); - $this->api()->clearEnvironmentsCache($environment->project); + $this->api->clearEnvironmentsCache($environment->project); $rebuildProperties = ['enable_smtp', 'restrict_robots']; $success = true; if ($result->countActivities() && !$noWait) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); - $success = $activityMonitor->waitMultiple($result->getActivities(), $this->getSelectedProject()); + $activityMonitor = $this->activityMonitor; + $success = $activityMonitor->waitMultiple($result->getActivities(), $project); } elseif (!$result->countActivities() && in_array($property, $rebuildProperties)) { - $this->redeployWarning(); + $this->api->redeployWarning(); } return $success ? 0 : 1; @@ -175,7 +168,7 @@ protected function setProperty($property, $value, Environment $environment, $noW * * @return string|false */ - protected function getType($property) + protected function getType(string $property): string|false { $writableProperties = [ 'enable_smtp' => 'boolean', @@ -185,16 +178,10 @@ protected function getType($property) 'type' => 'string', ]; - return isset($writableProperties[$property]) ? $writableProperties[$property] : false; + return $writableProperties[$property] ?? false; } - /** - * @param string $property - * @param string $value - * - * @return bool - */ - protected function validateValue($property, $value) + protected function validateValue(string $property, string $value, Environment $environment, Project $project): bool { $type = $this->getType($property); if (!$type) { @@ -205,19 +192,18 @@ protected function validateValue($property, $value) $valid = true; $message = ''; // @todo find out exactly how these should best be validated - $selectedEnvironment = $this->getSelectedEnvironment(); switch ($property) { case 'parent': if ($value === '-') { break; } - if ($value === $selectedEnvironment->id) { + if ($value === $environment->id) { $message = "An environment cannot be the parent of itself"; $valid = false; - } elseif (!$parentEnvironment = $this->api()->getEnvironment($value, $this->getSelectedProject())) { + } elseif (!$parentEnvironment = $this->api->getEnvironment($value, $project)) { $message = "Environment not found: $value"; $valid = false; - } elseif ($parentEnvironment->parent === $selectedEnvironment->id) { + } elseif ($parentEnvironment->parent === $environment->id) { $valid = false; } break; diff --git a/src/Command/Environment/EnvironmentInitCommand.php b/src/Command/Environment/EnvironmentInitCommand.php index 6c4c963004..e45c176380 100644 --- a/src/Command/Environment/EnvironmentInitCommand.php +++ b/src/Command/Environment/EnvironmentInitCommand.php @@ -1,50 +1,58 @@ setName('environment:init') - ->setDescription('Initialize an environment from a public Git repository') ->addArgument('url', InputArgument::REQUIRED, 'A URL to a Git repository') ->addOption('profile', null, InputOption::VALUE_REQUIRED, 'The name of the profile'); - if ($this->config()->get('service.name') === 'Platform.sh') { + if ($this->config->getStr('service.name') === 'Platform.sh') { $this->addExample('Initialize using the Platform.sh Go template', 'https://github.com/platformsh-templates/golang'); } - $this->addProjectOption() - ->addEnvironmentOption() - ->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); } /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input, true); - if (!$this->hasSelectedEnvironment()) { - $this->selectEnvironment($this->getSelectedProject()->default_branch); - } + $selection = $this->selector->getSelection($input, new SelectorConfig(envRequired: false, selectDefaultEnv: true)); - $environment = $this->getSelectedEnvironment(); + $environment = $selection->getEnvironment(); $url = $input->getArgument('url'); - $profile = $input->getOption('profile') ?: basename($url); + $profile = $input->getOption('profile') ?: basename((string) $url); - if (parse_url($url) === false) { + if (parse_url((string) $url) === false) { $this->stdErr->writeln(sprintf('Invalid repository URL: %s', $url)); return 1; @@ -53,7 +61,7 @@ protected function execute(InputInterface $input, OutputInterface $output) if (!$environment->operationAvailable('initialize', true)) { $this->stdErr->writeln(sprintf( "Operation not available: The environment %s can't be initialized.", - $environment->id + $environment->id, )); if ($environment->has_code) { @@ -65,8 +73,8 @@ protected function execute(InputInterface $input, OutputInterface $output) // Summarize this action with a message. $message = 'Initializing project '; - $message .= $this->api()->getProjectLabel($this->getSelectedProject()); - $message .= ', environment ' . $this->api()->getEnvironmentLabel($environment); + $message .= $this->api->getProjectLabel($selection->getProject()); + $message .= ', environment ' . $this->api->getEnvironmentLabel($environment); if ($input->getOption('profile')) { $message .= ' with profile ' . $profile . ' (' . $url . ')'; } else { @@ -76,12 +84,11 @@ protected function execute(InputInterface $input, OutputInterface $output) $result = $environment->runOperation('initialize', 'POST', ['profile' => $profile, 'repository' => $url]); - $this->api()->clearEnvironmentsCache($environment->project); + $this->api->clearEnvironmentsCache($environment->project); - if ($this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); - $success = $activityMonitor->waitMultiple($result->getActivities(), $this->getSelectedProject()); + if ($this->activityMonitor->shouldWait($input)) { + $activityMonitor = $this->activityMonitor; + $success = $activityMonitor->waitMultiple($result->getActivities(), $selection->getProject()); return $success ? 0 : 1; } diff --git a/src/Command/Environment/EnvironmentListCommand.php b/src/Command/Environment/EnvironmentListCommand.php index fd548047ac..d121851265 100644 --- a/src/Command/Environment/EnvironmentListCommand.php +++ b/src/Command/Environment/EnvironmentListCommand.php @@ -1,50 +1,66 @@ 'Machine name', 'Title', 'Status', 'Type', 'Created', 'Updated']; - private $defaultColumns = ['id', 'title', 'status', 'type']; - - protected $children = []; - - /** @var Environment */ - protected $currentEnvironment; - protected $mapping = []; - - /** @var \Platformsh\Cli\Service\PropertyFormatter */ - protected $formatter; + /** @var array */ + private array $tableHeader = ['ID', 'machine_name' => 'Machine name', 'Title', 'Status', 'Type', 'Created', 'Updated']; + /** @var string[] */ + private array $defaultColumns = ['id', 'title', 'status', 'type']; + + private Environment|false $currentEnvironment = false; + + /** @var array */ + private array $children = []; + + /** @var array */ + private array $mapping = []; + + public function __construct( + private readonly Api $api, + private readonly Config $config, + private readonly LocalProject $localProject, + private readonly PropertyFormatter $propertyFormatter, + private readonly Selector $selector, + private readonly Table $table, + ) { + parent::__construct(); + } - /** - * {@inheritdoc} - */ - protected function configure() + protected function configure(): void { $this - ->setName('environment:list') - ->setAliases(['environments', 'env']) - ->setDescription('Get a list of environments') ->addOption('no-inactive', 'I', InputOption::VALUE_NONE, 'Do not show inactive environments') - ->addOption('status', null, InputOption::VALUE_REQUIRED|InputOption::VALUE_IS_ARRAY, 'Filter environments by status (active, inactive, dirty, paused, deleting).' . "\n" . ArrayArgument::SPLIT_HELP) + ->addOption('status', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter environments by status (active, inactive, dirty, paused, deleting).' . "\n" . ArrayArgument::SPLIT_HELP) ->addOption('pipe', null, InputOption::VALUE_NONE, 'Output a simple list of environment IDs.') ->addOption('refresh', null, InputOption::VALUE_REQUIRED, 'Whether to refresh the list.', 1) - ->addOption('sort', null, InputOption::VALUE_REQUIRED, 'A property to sort by', 'title') + ->addOption('sort', null, InputOption::VALUE_REQUIRED, 'A property to sort by', 'title', ['id', 'title', 'status', 'name', 'machine_name', 'parent', 'created_at', 'updated_at']) ->addOption('reverse', null, InputOption::VALUE_NONE, 'Sort in reverse (descending) order') - ->addOption('type', null, InputOption::VALUE_REQUIRED|InputOption::VALUE_IS_ARRAY, 'Filter the list by environment type(s).' . "\n" . ArrayArgument::SPLIT_HELP); - Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); - $this->addProjectOption(); + ->addOption('type', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter the list by environment type(s).' . "\n" . ArrayArgument::SPLIT_HELP, null, ['development', 'staging', 'production']); + $this->table->configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); } /** @@ -58,7 +74,7 @@ protected function configure() * Children of all environments are stored in the * property $this->children. */ - protected function buildEnvironmentTree(array $environments, $parent = null) + protected function buildEnvironmentTree(array $environments, ?string $parent = null): array { $children = []; foreach ($environments as $environment) { @@ -68,7 +84,7 @@ protected function buildEnvironmentTree(array $environments, $parent = null) || ($parent === null && !isset($environments[$environment->parent]))) { $this->children[$environment->id] = $this->buildEnvironmentTree( $environments, - $environment->id + $environment->id, ); $children[$environment->id] = $environment; } @@ -78,16 +94,12 @@ protected function buildEnvironmentTree(array $environments, $parent = null) } /** - * Recursively build rows of the environment table. + * Recursively builds rows of the environment table. * * @param Environment[] $tree - * @param bool $indent - * @param int $indentAmount - * @param bool $indicateCurrent - * - * @return array + * @return array> */ - protected function buildEnvironmentRows(array $tree, $indent = true, $indicateCurrent = true, $indentAmount = 0) + protected function buildEnvironmentRows(array $tree, bool $indent = true, bool $indicateCurrent = true, int $indentAmount = 0): array { $rows = []; foreach ($tree as $environment) { @@ -121,8 +133,8 @@ protected function buildEnvironmentRows(array $tree, $indent = true, $indicateCu $row[] = new AdaptiveTableCell($this->formatEnvironmentStatus($environment->status), ['wrap' => false]); $row[] = new AdaptiveTableCell($environment->type, ['wrap' => false]); - $row[] = $this->formatter->format($environment->created_at, 'created_at'); - $row[] = $this->formatter->format($environment->updated_at, 'updated_at'); + $row[] = $this->propertyFormatter->format($environment->created_at, 'created_at'); + $row[] = $this->propertyFormatter->format($environment->updated_at, 'updated_at'); $rows[] = $row; if (isset($this->children[$environment->id])) { @@ -130,7 +142,7 @@ protected function buildEnvironmentRows(array $tree, $indent = true, $indicateCu $this->children[$environment->id], $indent, $indicateCurrent, - $indentAmount + 1 + $indentAmount + 1, ); $rows = array_merge($rows, $childRows); } @@ -142,17 +154,17 @@ protected function buildEnvironmentRows(array $tree, $indent = true, $indicateCu /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); + $selection = $this->selector->getSelection($input); $refresh = $input->hasOption('refresh') && $input->getOption('refresh'); $progress = new ProgressMessage($output); $progress->showIfOutputDecorated('Loading environments...'); - $project = $this->getSelectedProject(); - $environments = $this->api()->getEnvironments($project, $refresh ? true : null); + $project = $selection->getProject(); + $environments = $this->api->getEnvironments($project, $refresh ? true : null); $progress->done(); @@ -170,7 +182,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->filterEnvironments($environments, $filters); if ($input->getOption('sort')) { - $this->api()->sortResources($environments, $input->getOption('sort')); + $this->api->sortResources($environments, $input->getOption('sort')); } if ($input->getOption('reverse')) { $environments = array_reverse($environments, true); @@ -191,20 +203,18 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln('No environments found (filters in use: ' . $filtersUsed . ').'); } else { $this->stdErr->writeln( - 'No environments found.' + 'No environments found.', ); } return 0; } - $project = $this->getSelectedProject(); - $this->currentEnvironment = $this->getCurrentEnvironment($project); + $project = $selection->getProject(); + $this->currentEnvironment = $this->selector->getCurrentEnvironment($project); - if (($currentProject = $this->getCurrentProject()) && $currentProject->id === $project->id) { - /** @var \Platformsh\Cli\Local\LocalProject $localProject */ - $localProject = $this->getService('local.project'); - $projectConfig = $localProject->getProjectConfig($this->getProjectRoot()); + if (($currentProject = $this->selector->getCurrentProject()) && $currentProject->id === $project->id) { + $projectConfig = $this->localProject->getProjectConfig((string) $this->selector->getProjectRoot()); if (isset($projectConfig['mapping'])) { $this->mapping = $projectConfig['mapping']; } @@ -212,20 +222,14 @@ protected function execute(InputInterface $input, OutputInterface $output) $tree = $this->buildEnvironmentTree($environments); - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $this->formatter = $this->getService('property_formatter'); - - if ($table->formatIsMachineReadable()) { - $table->render($this->buildEnvironmentRows($tree, false, false), $this->tableHeader, $this->defaultColumns); + if ($this->table->formatIsMachineReadable()) { + $this->table->render($this->buildEnvironmentRows($tree, false, false), $this->tableHeader, $this->defaultColumns); return 0; } $this->stdErr->writeln("Your environments are: "); - $table->render($this->buildEnvironmentRows($tree), $this->tableHeader, $this->defaultColumns); + $this->table->render($this->buildEnvironmentRows($tree), $this->tableHeader, $this->defaultColumns); if (!$this->currentEnvironment) { return 0; @@ -234,52 +238,47 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln("* - Indicates the current environment\n"); $currentEnvironment = $this->currentEnvironment; - $executable = $this->config()->get('application.executable'); + $executable = $this->config->getStr('application.executable'); $this->stdErr->writeln( - 'Check out a different environment by running ' . $executable . ' checkout [id]' + 'Check out a different environment by running ' . $executable . ' checkout [id]', ); if ($currentEnvironment->operationAvailable('branch')) { $this->stdErr->writeln( - 'Branch a new environment by running ' . $executable . ' environment:branch [new-name]' + 'Branch a new environment by running ' . $executable . ' environment:branch [new-name]', ); } if ($currentEnvironment->operationAvailable('activate')) { $this->stdErr->writeln( - 'Activate the current environment by running ' . $executable . ' environment:activate' + 'Activate the current environment by running ' . $executable . ' environment:activate', ); } if ($currentEnvironment->operationAvailable('delete')) { $this->stdErr->writeln( - 'Delete the current environment by running ' . $executable . ' environment:delete' + 'Delete the current environment by running ' . $executable . ' environment:delete', ); } if ($currentEnvironment->operationAvailable('backup')) { $this->stdErr->writeln( - 'Make a backup of the current environment by running ' . $executable . ' backup' + 'Make a backup of the current environment by running ' . $executable . ' backup', ); } if ($currentEnvironment->operationAvailable('merge')) { $this->stdErr->writeln( - 'Merge the current environment by running ' . $executable . ' environment:merge' + 'Merge the current environment by running ' . $executable . ' environment:merge', ); } if ($currentEnvironment->operationAvailable('synchronize')) { $this->stdErr->writeln( - 'Sync the current environment by running ' . $executable . ' environment:synchronize' + 'Sync the current environment by running ' . $executable . ' environment:synchronize', ); } return 0; } - /** - * @param string $status - * - * @return string - */ - protected function formatEnvironmentStatus($status) + protected function formatEnvironmentStatus(string $status): string { if ($status == 'dirty') { $status = 'In progress'; @@ -294,45 +293,16 @@ protected function formatEnvironmentStatus($status) * @param Environment[] &$environments * @param array $filters */ - protected function filterEnvironments(array &$environments, array $filters) + protected function filterEnvironments(array &$environments, array $filters): void { if (!empty($filters['no-inactive'])) { - $environments = array_filter($environments, function ($environment) { - return $environment->status !== 'inactive'; - }); + $environments = array_filter($environments, fn($environment): bool => $environment->status !== 'inactive'); } if (!empty($filters['type'])) { - $environments = array_filter($environments, function (Environment $environment) use ($filters) { - return \in_array($environment->type, $filters['type']); - }); + $environments = array_filter($environments, fn(Environment $environment): bool => \in_array($environment->type, $filters['type'])); } if (!empty($filters['status'])) { - $environments = array_filter($environments, function (Environment $environment) use ($filters) { - return \in_array($environment->status, $filters['status']); - }); - } - } - - /** - * {@inheritDoc} - */ - public function completeOptionValues($optionName, CompletionContext $context) - { - if ($optionName === 'type') { - // @todo fetch types from the project if known? not necessary until custom types are available - return ['development', 'staging', 'production']; + $environments = array_filter($environments, fn(Environment $environment): bool => \in_array($environment->status, $filters['status'])); } - if ($optionName === 'sort') { - return ['id', 'title', 'status', 'name', 'machine_name', 'parent', 'created_at', 'updated_at']; - } - return []; - } - - /** - * {@inheritDoc} - */ - public function completeArgumentValues($argumentName, CompletionContext $context) - { - return []; } } diff --git a/src/Command/Environment/EnvironmentLogCommand.php b/src/Command/Environment/EnvironmentLogCommand.php index 5f26f5c4dc..60aeb1f082 100644 --- a/src/Command/Environment/EnvironmentLogCommand.php +++ b/src/Command/Environment/EnvironmentLogCommand.php @@ -1,32 +1,48 @@ setName('environment:logs') - ->setAliases(['log']) - ->setDescription("Read an environment's logs") - ->addArgument('type', InputArgument::OPTIONAL, 'The log type, e.g. "access" or "error"') + ->addArgument('type', InputArgument::OPTIONAL, 'The log type, e.g. "access" or "error"', null, [ + 'access', + 'error', + 'cron', + 'deploy', + 'app', + ]) ->addOption('lines', null, InputOption::VALUE_REQUIRED, 'The number of lines to show', 100) ->addOption('tail', null, InputOption::VALUE_NONE, 'Continuously tail the log'); - $this->addProjectOption() - ->addEnvironmentOption() - ->addRemoteContainerOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->selector->addRemoteContainerOptions($this->getDefinition()); + $this->addCompleter($this->selector); $this->setHiddenAliases(['logs']); $this->addExample('Display a choice of logs that can be read'); $this->addExample('Read the deploy log', 'deploy'); @@ -34,17 +50,15 @@ protected function configure() $this->addExample('Read the last 500 lines of the cron log', 'cron --lines 500'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->chooseEnvFilter = $this->filterEnvsMaybeActive(); - $this->validateInput($input); + $selection = $this->selector->getSelection($input, new SelectorConfig(chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive())); if ($input->getOption('tail') && $this->runningViaMulti) { throw new InvalidArgumentException('The --tail option cannot be used with "multi"'); } - $container = $this->selectRemoteContainer($input); - $host = $this->selectHost($input, false, $container); + $host = $this->selector->getHostFromSelection($input, $selection); $logDir = '/var/log'; @@ -52,31 +66,28 @@ protected function execute(InputInterface $input, OutputInterface $output) // the SSH URL contains something like "ssh://1.ent-" or "1.ent-" or "ent-". if (preg_match('%(^|[/.])ent-[a-z0-9]%', $host->getLabel())) { $logDir = '/var/log/platform/"$USER"'; - $this->debug('Detected Dedicated environment: using log directory: ' . $logDir); + $this->io->debug('Detected Dedicated environment: using log directory: ' . $logDir); } // Select the log file that the user specified. if ($logType = $input->getArgument('type')) { // @todo this might need to be cleverer - if (substr($logType, -4) === '.log') { - $logType = substr($logType, 0, strlen($logType) - 4); + if (str_ends_with((string) $logType, '.log')) { + $logType = substr((string) $logType, 0, strlen((string) $logType) - 4); } $logFilename = $logDir . '/' . OsUtil::escapePosixShellArg($logType . '.log'); } elseif (!$input->isInteractive()) { $this->stdErr->writeln('No log type specified.'); return 1; } else { - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); // Read the list of files from the environment. $cacheKey = sprintf('log-files:%s', $host->getCacheKey()); - /** @var \Doctrine\Common\Cache\CacheProvider $cache */ - $cache = $this->getService('cache'); + $cache = $this->cacheProvider; if (!$result = $cache->fetch($cacheKey)) { $result = $host->runCommand('echo -n _BEGIN_FILE_LIST_; ls -1 ' . $logDir . '/*.log; echo -n _END_FILE_LIST_'); if (is_string($result)) { - $result = trim(StringUtil::between($result, '_BEGIN_FILE_LIST_', '_END_FILE_LIST_')); + $result = trim((string) StringUtil::between($result, '_BEGIN_FILE_LIST_', '_END_FILE_LIST_')); } // Cache the list for 1 day. @@ -91,10 +102,8 @@ protected function execute(InputInterface $input, OutputInterface $output) $files = $result && is_string($result) ? explode("\n", $result) : $defaultFiles; // Ask the user to choose a file. - $files = array_combine($files, array_map(function ($file) { - return str_replace('.log', '', basename(trim($file))); - }, $files)); - $logFilename = $questionHelper->choose($files, 'Enter a number to choose a log: '); + $files = array_combine($files, array_map(fn($file): string => str_replace('.log', '', basename(trim((string) $file))), $files)); + $logFilename = $this->questionHelper->choose($files, 'Enter a number to choose a log: '); } $command = sprintf('tail -n %1$d %2$s', $input->getOption('lines'), $logFilename); @@ -106,31 +115,4 @@ protected function execute(InputInterface $input, OutputInterface $output) return $host->runCommandDirect($command); } - - /** - * {@inheritdoc} - */ - public function completeOptionValues($optionName, CompletionContext $context) - { - return []; - } - - /** - * {@inheritdoc} - */ - public function completeArgumentValues($argumentName, CompletionContext $context) - { - $values = []; - if ($argumentName === 'type') { - $values = [ - 'access', - 'error', - 'cron', - 'deploy', - 'app', - ]; - } - - return $values; - } } diff --git a/src/Command/Environment/EnvironmentMergeCommand.php b/src/Command/Environment/EnvironmentMergeCommand.php index 8691955200..d454f3b3cd 100644 --- a/src/Command/Environment/EnvironmentMergeCommand.php +++ b/src/Command/Environment/EnvironmentMergeCommand.php @@ -1,52 +1,68 @@ setName('environment:merge') - ->setAliases(['merge']) - ->setDescription('Merge an environment') ->addArgument('environment', InputArgument::OPTIONAL, 'The environment to merge'); - $this->addResourcesInitOption(['child', 'default', 'minimum', 'manual']); - $this->addProjectOption() - ->addEnvironmentOption() - ->addWaitOptions(); + $this->resourcesUtil->addOption($this->getDefinition(), $this->validResourcesInitOptions); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); $this->addExample('Merge the environment "sprint-2" into its parent', 'sprint-2'); $this->setHelp( - 'This command will initiate a Git merge of the specified environment into its parent environment.' + 'This command will initiate a Git merge of the specified environment into its parent environment.', ); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); + $selection = $this->selector->getSelection($input); - $selectedEnvironment = $this->getSelectedEnvironment(); + $selectedEnvironment = $selection->getEnvironment(); $environmentId = $selectedEnvironment->id; if (!$selectedEnvironment->operationAvailable('merge', true)) { $this->stdErr->writeln(sprintf( "Operation not available: The environment %s can't be merged.", - $environmentId + $environmentId, )); if ($selectedEnvironment->getProperty('has_remote', false) === true - && ($integration = $this->api()->getCodeSourceIntegration($this->getSelectedProject())) + && ($integration = $this->api->getCodeSourceIntegration($selection->getProject())) && $integration->getProperty('fetch_branches', false) === true) { $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf("The project's code is managed externally through its %s integration.", $integration->type)); - if ($this->config()->isCommandEnabled('integration:get')) { - $this->stdErr->writeln(sprintf('To view the integration, run: %s integration:get %s', $this->config()->get('application.executable'), OsUtil::escapeShellArg($integration->id))); + if ($this->config->isCommandEnabled('integration:get')) { + $this->stdErr->writeln(sprintf('To view the integration, run: %s integration:get %s', $this->config->getStr('application.executable'), OsUtil::escapeShellArg($integration->id))); } } elseif ($selectedEnvironment->parent === null) { $this->stdErr->writeln(''); @@ -60,7 +76,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } // Validate the --resources-init option. - $resourcesInit = $this->validateResourcesInitInput($input, $this->getSelectedProject()); + $resourcesInit = $this->resourcesUtil->validateInput($input, $selection->getProject(), $this->validResourcesInitOptions); if ($resourcesInit === false) { return 1; } @@ -70,21 +86,19 @@ protected function execute(InputInterface $input, OutputInterface $output) $confirmText = sprintf( 'Are you sure you want to merge %s into its parent, %s?', $environmentId, - $parentId + $parentId, ); - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - if (!$questionHelper->confirm($confirmText)) { + if (!$this->questionHelper->confirm($confirmText)) { return 1; } $this->stdErr->writeln(sprintf( 'Merging %s into %s', $environmentId, - $parentId + $parentId, )); - $this->api()->clearEnvironmentsCache($selectedEnvironment->project); + $this->api->clearEnvironmentsCache($selectedEnvironment->project); $params = []; if ($resourcesInit !== null) { @@ -92,10 +106,9 @@ protected function execute(InputInterface $input, OutputInterface $output) } $result = $selectedEnvironment->runOperation('merge', 'POST', $params); - if ($this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); - $success = $activityMonitor->waitMultiple($result->getActivities(), $this->getSelectedProject()); + if ($this->activityMonitor->shouldWait($input)) { + $activityMonitor = $this->activityMonitor; + $success = $activityMonitor->waitMultiple($result->getActivities(), $selection->getProject()); if (!$success) { return 1; } diff --git a/src/Command/Environment/EnvironmentPauseCommand.php b/src/Command/Environment/EnvironmentPauseCommand.php index d36df5324a..54d3804bd9 100644 --- a/src/Command/Environment/EnvironmentPauseCommand.php +++ b/src/Command/Environment/EnvironmentPauseCommand.php @@ -1,41 +1,50 @@ setName('environment:pause') - ->setDescription('Pause an environment'); - $this->addProjectOption() - ->addEnvironmentOption(); - $this->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); $this->setHelp(self::PAUSE_HELP); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->chooseEnvFilter = $this->filterEnvsMaybeActive(); - $this->validateInput($input); - - $environment = $this->getSelectedEnvironment(); + $selection = $this->selector->getSelection($input, new SelectorConfig(chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive())); + $environment = $selection->getEnvironment(); if ($environment->status === 'paused') { $this->stdErr->writeln(sprintf( 'The environment %s is already paused.', - $this->api()->getEnvironmentLabel($environment) + $this->api->getEnvironmentLabel($environment), )); return 0; } @@ -43,7 +52,7 @@ protected function execute(InputInterface $input, OutputInterface $output) if (!$environment->operationAvailable('pause', true)) { $this->stdErr->writeln(sprintf( "Operation not available: The environment %s can't be paused.", - $this->api()->getEnvironmentLabel($environment, 'error') + $this->api->getEnvironmentLabel($environment, 'error'), )); if (!$environment->isActive()) { @@ -52,21 +61,17 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); $text = self::PAUSE_HELP . "\n\n" . sprintf('Are you sure you want to pause the environment %s?', $environment->id); - if (!$questionHelper->confirm($text)) { + if (!$this->questionHelper->confirm($text)) { return 1; } $result = $environment->runOperation('pause'); - $this->api()->clearEnvironmentsCache($environment->project); + $this->api->clearEnvironmentsCache($environment->project); - if ($this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); - $success = $activityMonitor->waitMultiple($result->getActivities(), $this->getSelectedProject()); + if ($this->activityMonitor->shouldWait($input)) { + $activityMonitor = $this->activityMonitor; + $success = $activityMonitor->waitMultiple($result->getActivities(), $selection->getProject()); if (!$success) { return 1; } diff --git a/src/Command/Environment/EnvironmentPushCommand.php b/src/Command/Environment/EnvironmentPushCommand.php index 75e21d0d3d..bac9247a30 100644 --- a/src/Command/Environment/EnvironmentPushCommand.php +++ b/src/Command/Environment/EnvironmentPushCommand.php @@ -1,26 +1,62 @@ setName('environment:push') - ->setAliases(['push']) - ->setDescription('Push code to an environment') ->addArgument('source', InputArgument::OPTIONAL, 'The Git source ref, e.g. a branch name or a commit hash.', 'HEAD') ->addOption('target', null, InputOption::VALUE_REQUIRED, 'The target branch name. Defaults to the current branch.') ->addOption('force', 'f', InputOption::VALUE_NONE, 'Allow non-fast-forward updates') @@ -31,59 +67,58 @@ protected function configure() ->addOption('parent', null, InputOption::VALUE_REQUIRED, 'Set the environment parent (only used with --activate)') ->addOption('type', null, InputOption::VALUE_REQUIRED, 'Set the environment type (only used with --activate )') ->addOption('no-clone-parent', null, InputOption::VALUE_NONE, "Do not clone the parent branch's data (only used with --activate)"); - $this->addResourcesInitOption( - ['parent', 'default', 'minimum', 'manual'], + $this->resourcesUtil->addOption( + $this->getDefinition(), + $this->validResourcesInitOptions, 'Set the resources to use for new services: parent, default, minimum, or manual.' - . "\n" . 'Currently the default is "default" but this will change to "parent" in future.' + . "\n" . 'Currently the default is "default" but this will change to "parent" in future.', ); - $this->addWaitOptions(); - $this->addProjectOption() - ->addEnvironmentOption(); + $this->activityMonitor->addWaitOptions($this->getDefinition()); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); Ssh::configureInput($this->getDefinition()); $this->addExample('Push code to the current environment'); $this->addExample('Push code, without waiting for deployment', '--no-wait'); $this->addExample( 'Push code, branching or activating the environment as a child of \'develop\'', - '--activate --parent develop' + '--activate --parent develop', ); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->warnAboutDeprecatedOptions(['branch'], 'The option --%s is deprecated and will be removed in future. Use --activate, which has the same effect.'); - - /** @var \Platformsh\Cli\Service\Git $git */ - $git = $this->getService('git'); - $gitRoot = $git->getRoot(); + $this->io->warnAboutDeprecatedOptions(['branch'], 'The option --%s is deprecated and will be removed in future. Use --activate, which has the same effect.'); + $gitRoot = $this->git->getRoot(); if ($gitRoot === false) { $this->stdErr->writeln('This command can only be run from inside a Git repository.'); return 1; } - $git->setDefaultRepositoryDir($gitRoot); + $this->git->setDefaultRepositoryDir($gitRoot); - $this->validateInput($input, true); - $project = $this->getSelectedProject(); - $currentProject = $this->getCurrentProject(); - $this->ensurePrintSelectedProject(); + $selection = $this->selector->getSelection($input, new SelectorConfig(envRequired: false)); + $project = $selection->getProject(); + $currentProject = $this->selector->getCurrentProject(); + $this->selector->ensurePrintedSelection($selection); $this->stdErr->writeln(''); // Validate the --resources-init option. - $resourcesInit = $this->validateResourcesInitInput($input, $project); + $resourcesInit = $this->resourcesUtil->validateInput($input, $project, $this->validResourcesInitOptions); if ($resourcesInit === false) { return 1; } if ($currentProject && $currentProject->id !== $project->id) { - $this->stdErr->writeln('The current repository is linked to another project: ' . $this->api()->getProjectLabel($currentProject, 'comment')); + $this->stdErr->writeln('The current repository is linked to another project: ' . $this->api->getProjectLabel($currentProject, 'comment')); if ($input->getOption('set-upstream')) { $this->stdErr->writeln('It will be changed to link to the selected project.'); } else { $this->stdErr->writeln('To link it to the selected project for future actions, use the: --set-upstream (-u) option'); $this->stdErr->writeln(sprintf( 'Alternatively, run: %s set-remote %s', - $this->config()->get('application.executable'), - OsUtil::escapeShellArg($project->id) + $this->config->getStr('application.executable'), + OsUtil::escapeShellArg($project->id), )); } @@ -97,32 +132,29 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($source === '') { $this->stdErr->writeln('The argument cannot be specified as an empty string.'); return 1; - } elseif (strpos($source, ':') !== false - || !($sourceRevision = $git->execute(['rev-parse', '--verify', $source]))) { + } elseif (str_contains((string) $source, ':') + || !($sourceRevision = $this->git->execute(['rev-parse', '--verify', $source]))) { $this->stdErr->writeln(sprintf('Invalid source ref: %s', $source)); return 1; } - $this->debug(sprintf('Source revision: %s', $sourceRevision)); - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); + $this->io->debug(sprintf('Source revision: %s', $sourceRevision)); // Find the target branch name (--target, the name of the current // environment, or the Git branch name). if ($input->getOption('target')) { $target = $input->getOption('target'); - } elseif ($this->hasSelectedEnvironment()) { - $target = $this->getSelectedEnvironment()->id; + } elseif ($selection->hasEnvironment()) { + $target = $selection->getEnvironment()->id; } else { - $allEnvironments = $this->api()->getEnvironments($project); - $currentBranch = $git->getCurrentBranch(); + $allEnvironments = $this->api->getEnvironments($project); + $currentBranch = $this->git->getCurrentBranch(); if ($currentBranch !== false && isset($allEnvironments[$currentBranch])) { $target = $currentBranch; } else { $default = $currentBranch !== false ? $currentBranch : null; - $target = $questionHelper->askInput('Enter the target branch name', $default, array_keys($allEnvironments)); - if ($target === null) { + $target = $this->questionHelper->askInput('Enter the target branch name', $default, array_keys($allEnvironments)); + if ($target === '') { $this->stdErr->writeln('A target branch name (--target) is required.'); return 1; } @@ -131,7 +163,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } /** @var Environment|false $targetEnvironment The target environment, which may not exist yet. */ - $targetEnvironment = $this->api()->getEnvironment($target, $project); + $targetEnvironment = $this->api->getEnvironment($target, $project); // Determine whether to activate the environment. $activateRequested = $this->determineShouldActivate($input, $project, $target, $targetEnvironment); @@ -148,16 +180,16 @@ protected function execute(InputInterface $input, OutputInterface $output) $codeAlreadyUpToDate = $targetEnvironment && $sourceRevision === $targetEnvironment->head_commit; - $projectLabel = $this->api()->getProjectLabel($project, $otherProject ? 'comment' : 'info'); + $projectLabel = $this->api->getProjectLabel($project, $otherProject ? 'comment' : 'info'); if ($targetEnvironment) { if ($codeAlreadyUpToDate) { - $environmentLabel = $this->api()->getEnvironmentLabel($targetEnvironment); + $environmentLabel = $this->api->getEnvironmentLabel($targetEnvironment); $this->stdErr->writeln(sprintf('The environment %s is already up to date with the source ref, %s.', $environmentLabel, $source)); if (!$activateRequested || !in_array($targetEnvironment->status, ['inactive', 'paused'])) { return 0; } } else { - $environmentLabel = $this->api()->getEnvironmentLabel($targetEnvironment, $mayBeProduction ? 'comment' : 'info'); + $environmentLabel = $this->api->getEnvironmentLabel($targetEnvironment, $mayBeProduction ? 'comment' : 'info'); $this->stdErr->writeln(sprintf('Pushing %s to the environment %s.', $source, $environmentLabel)); } if ($activateRequested && $targetEnvironment->status === 'inactive') { @@ -170,37 +202,34 @@ protected function execute(InputInterface $input, OutputInterface $output) } else { $targetLabel = $mayBeProduction ? '' . $target . '' : '' . $target . ''; $this->stdErr->writeln(sprintf('Pushing %s to the branch %s of project %s', $source, $targetLabel, $projectLabel)); - if ($activateRequested && !$this->hasExternalGitHost($project)) { + if ($activateRequested && !$this->projectSshInfo->hasExternalGitHost($project)) { $this->stdErr->writeln('It will be created as an active environment.'); } } $this->stdErr->writeln(''); - if (!$questionHelper->confirm('Are you sure you want to continue?')) { + if (!$this->questionHelper->confirm('Are you sure you want to continue?')) { return 1; } $this->stdErr->writeln(''); $activities = []; - /** @var \Platformsh\Cli\Local\LocalProject $localProject */ - $localProject = $this->getService('local.project'); - - $remoteName = $this->config()->get('detection.git_remote_name'); + $remoteName = $this->config->getStr('detection.git_remote_name'); // Map the current directory to the project. if ($input->getOption('set-upstream') && (!$currentProject || $currentProject->id !== $project->id)) { - $this->stdErr->writeln(sprintf('Mapping the directory %s to the project %s', $gitRoot, $this->api()->getProjectLabel($project))); + $this->stdErr->writeln(sprintf('Mapping the directory %s to the project %s', $gitRoot, $this->api->getProjectLabel($project))); $this->stdErr->writeln(''); - $localProject->mapDirectory($gitRoot, $project); + $this->localProject->mapDirectory($gitRoot, $project); $currentProject = $project; $remoteRepoSpec = $remoteName; } elseif ($currentProject && $currentProject->id === $project->id) { // Ensure the current project's Git remote conforms. - $localProject->ensureGitRemote($gitRoot, $gitUrl); + $this->localProject->ensureGitRemote($gitRoot, $gitUrl); $remoteRepoSpec = $remoteName; - } elseif ($git->getConfig("remote.$remoteName.url") === $gitUrl) { + } elseif ($this->git->getConfig("remote.$remoteName.url") === $gitUrl) { $remoteRepoSpec = $remoteName; } else { // If pushing to a project that isn't set as the current one, then @@ -220,7 +249,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $gitArgs[] = '--' . $option; } } - if ($this->stdErr->isDecorated() && $this->isTerminal(STDERR)) { + if ($this->stdErr->isDecorated() && $this->io->isTerminal(STDERR)) { $gitArgs[] = '--progress'; } if ($activateRequested) { @@ -242,35 +271,33 @@ protected function execute(InputInterface $input, OutputInterface $output) // Build the SSH command to use with Git. $extraSshOptions = []; $env = []; - if (!$this->shouldWait($input)) { + if (!$this->activityMonitor->shouldWait($input)) { $extraSshOptions[] = 'SendEnv PLATFORMSH_PUSH_NO_WAIT'; $env['PLATFORMSH_PUSH_NO_WAIT'] = '1'; } - $git->setExtraSshOptions($extraSshOptions); + $this->git->setExtraSshOptions($extraSshOptions); // Perform the push, capturing the Process object so that the STDERR // output can be read. - /** @var \Platformsh\Cli\Service\Shell $shell */ - $shell = $this->getService('shell'); - $process = $shell->executeCaptureProcess(\array_merge(['git'], $gitArgs), $gitRoot, false, false, $env + $git->setupSshEnv($gitUrl), $this->config()->get('api.git_push_timeout')); + $shell = $this->shell; + $process = $shell->executeCaptureProcess(\array_merge(['git'], $gitArgs), $gitRoot, false, false, $env + $this->git->setupSshEnv($gitUrl), (int) $this->config->get('api.git_push_timeout')); if ($process->getExitCode() !== 0) { - /** @var \Platformsh\Cli\Service\SshDiagnostics $diagnostics */ - $diagnostics = $this->getService('ssh_diagnostics'); + $diagnostics = $this->sshDiagnostics; $diagnostics->diagnoseFailure($project->getGitUrl(), $process); return $process->getExitCode(); } // Clear the environment cache after pushing. - $this->api()->clearEnvironmentsCache($project->id); + $this->api->clearEnvironmentsCache($project->id); $log = $process->getErrorOutput(); // Check the push log for services that need resources configured ("flexible resources"). - if (\strpos($log, 'Invalid deployment') !== false - && \strpos($log, 'Resources must be configured') !== false) { + if (str_contains($log, 'Invalid deployment') + && str_contains($log, 'Resources must be configured')) { $this->stdErr->writeln(''); $this->stdErr->writeln('The push completed but resources must be configured before deployment can succeed.'); - if ($this->config()->isCommandEnabled('resources:set')) { + if ($this->config->isCommandEnabled('resources:set')) { $cmd = 'resources:set'; if ($input->getOption('project')) { $cmd .= ' -p ' . OsUtil::escapeShellArg($input->getOption('project')); @@ -283,17 +310,17 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf( 'Configure resources for the environment by running: %s %s', - $this->config()->get('application.executable'), - $cmd + $this->config->getStr('application.executable'), + $cmd, )); } return self::PUSH_FAILURE_EXIT_CODE; } // Check the push log for other possible deployment error messages. - $messages = $this->config()->getWithDefault('detection.push_deploy_error_messages', []); + $messages = (array) $this->config->get('detection.push_deploy_error_messages'); foreach ($messages as $message) { - if (\strpos($log, $message) !== false) { + if (str_contains($log, (string) $message)) { $this->stdErr->writeln(''); $this->stdErr->writeln(\sprintf('The push completed but there was a deployment error ("%s").', $message)); @@ -307,13 +334,13 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($targetEnvironment) { $targetEnvironment->refresh(); } else { - $targetEnvironment = $this->api()->getEnvironment($target, $project); + $targetEnvironment = $this->api->getEnvironment($target, $project); if (!$targetEnvironment) { $this->stdErr->writeln('The target environment ' . $target . ' cannot be activated (not found).'); - if ($this->hasExternalGitHost($project) && ($integration = $this->api()->getCodeSourceIntegration($project))) { + if ($this->projectSshInfo->hasExternalGitHost($project) && ($integration = $this->api->getCodeSourceIntegration($project))) { $this->stdErr->writeln(sprintf("Environments may be created through the project's %s integration.", $integration->type)); - if ($this->config()->isCommandEnabled('integration:get')) { - $this->stdErr->writeln(sprintf('To view the integration, run: %s integration:get %s', $this->config()->get('application.executable'), OsUtil::escapeShellArg($integration->id))); + if ($this->config->isCommandEnabled('integration:get')) { + $this->stdErr->writeln(sprintf('To view the integration, run: %s integration:get %s', $this->config->getStr('application.executable'), OsUtil::escapeShellArg($integration->id))); } } return 1; @@ -323,9 +350,8 @@ protected function execute(InputInterface $input, OutputInterface $output) } // Wait if there are still activities. - if ($this->shouldWait($input) && !empty($activities)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $monitor */ - $monitor = $this->getService('activity_monitor'); + if ($this->activityMonitor->shouldWait($input) && !empty($activities)) { + $monitor = $this->activityMonitor; $success = $monitor->waitMultiple($activities, $project); if (!$success) { return 1; @@ -336,7 +362,7 @@ protected function execute(InputInterface $input, OutputInterface $output) if (!$currentProject && !$input->getOption('set-upstream')) { $this->stdErr->writeln(''); $this->stdErr->writeln('To set the project as the remote for this repository, run:'); - $this->stdErr->writeln(sprintf('%s set-remote %s', $this->config()->get('application.executable'), OsUtil::escapeShellArg($project->id))); + $this->stdErr->writeln(sprintf('%s set-remote %s', $this->config->getStr('application.executable'), OsUtil::escapeShellArg($project->id))); } return 0; @@ -348,14 +374,10 @@ protected function execute(InputInterface $input, OutputInterface $output) * This may branch (creating a new environment), or resume or activate, * depending on the current state. * - * @param Environment $targetEnvironment - * @param string|null $parentId - * @param bool $cloneParent - * @param string|null $type - * - * @return array A list of activities, if any. + * @return Activity[] */ - private function ensureActive(Environment $targetEnvironment, $parentId, $cloneParent, $type) { + private function ensureActive(Environment $targetEnvironment, ?string $parentId, bool $cloneParent, ?string $type): array + { $activities = []; $updates = []; if ($parentId !== null && $targetEnvironment->parent !== $parentId) { @@ -368,23 +390,23 @@ private function ensureActive(Environment $targetEnvironment, $parentId, $cloneP $updates['type'] = $type; } if (!empty($updates)) { - $this->debug('Updating environment ' . $targetEnvironment->id . ' with properties: ' . json_encode($updates)); + $this->io->debug('Updating environment ' . $targetEnvironment->id . ' with properties: ' . json_encode($updates)); $activities = array_merge( $activities, - $targetEnvironment->update($updates)->getActivities() + $targetEnvironment->update($updates)->getActivities(), ); } if ($targetEnvironment->status === 'dirty') { $targetEnvironment->refresh(); } if ($targetEnvironment->status === 'inactive' && $targetEnvironment->operationAvailable('activate')) { - $this->debug('Activating inactive environment ' . $targetEnvironment->id); + $this->io->debug('Activating inactive environment ' . $targetEnvironment->id); $activities = array_merge($activities, $targetEnvironment->runOperation('activate')->getActivities()); } elseif ($targetEnvironment->status === 'paused' && $targetEnvironment->operationAvailable('resume')) { - $this->debug('Resuming paused environment ' . $targetEnvironment->id); + $this->io->debug('Resuming paused environment ' . $targetEnvironment->id); $activities = array_merge($activities, $targetEnvironment->runOperation('resume')->getActivities()); } - $this->api()->clearEnvironmentsCache($targetEnvironment->project); + $this->api->clearEnvironmentsCache($targetEnvironment->project); return $activities; } @@ -399,7 +421,7 @@ private function ensureActive(Environment $targetEnvironment, $parentId, $cloneP * * @return bool */ - private function determineShouldActivate(InputInterface $input, Project $project, $target, $targetEnvironment) + private function determineShouldActivate(InputInterface $input, Project $project, string $target, Environment|false $targetEnvironment): bool { if ($target === $project->default_branch || ($targetEnvironment && $targetEnvironment->is_main)) { return false; @@ -414,25 +436,23 @@ private function determineShouldActivate(InputInterface $input, Project $project // The environment cannot be created via a push if the Git host is // external. This would indicate that a code source integration is // enabled on the project (e.g. with GitHub, GitLab or Bitbucket). - if (!$targetEnvironment && $this->hasExternalGitHost($project)) { + if (!$targetEnvironment && $this->projectSshInfo->hasExternalGitHost($project)) { return false; } if ($targetEnvironment && $targetEnvironment->is_dirty) { $targetEnvironment->refresh(); } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); if (!$targetEnvironment) { $questionText = sprintf('Create %s as an active environment?', $target); } elseif ($targetEnvironment->status === 'inactive') { - $questionText = sprintf('Do you want to activate the target environment %s?', $this->api()->getEnvironmentLabel($targetEnvironment, 'info', false)); + $questionText = sprintf('Do you want to activate the target environment %s?', $this->api->getEnvironmentLabel($targetEnvironment, 'info', false)); } elseif ($targetEnvironment->status === 'paused') { - $questionText = sprintf('Do you want to resume the paused target environment %s?', $this->api()->getEnvironmentLabel($targetEnvironment, 'info', false)); + $questionText = sprintf('Do you want to resume the paused target environment %s?', $this->api->getEnvironmentLabel($targetEnvironment, 'info', false)); } else { return false; } - $activateRequested = $questionHelper->confirm($questionText); + $activateRequested = $this->questionHelper->confirm($questionText); $this->stdErr->writeln(''); return $activateRequested; } diff --git a/src/Command/Environment/EnvironmentRedeployCommand.php b/src/Command/Environment/EnvironmentRedeployCommand.php index 3662ee0c92..55cf460128 100644 --- a/src/Command/Environment/EnvironmentRedeployCommand.php +++ b/src/Command/Environment/EnvironmentRedeployCommand.php @@ -1,34 +1,43 @@ setName('environment:redeploy') - ->setAliases(['redeploy']) - ->setDescription('Redeploy an environment'); - $this->addProjectOption() - ->addEnvironmentOption(); - $this->addWaitOptions(); + parent::__construct(); + } + protected function configure(): void + { + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->chooseEnvFilter = $this->filterEnvsByStatus(['active', 'paused']); - $this->validateInput($input); + $selection = $this->selector->getSelection($input, new SelectorConfig(chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive())); - $environment = $this->getSelectedEnvironment(); + $environment = $selection->getEnvironment(); if (!$environment->operationAvailable('redeploy', true)) { $this->stdErr->writeln( - "Operation not available: The environment " . $this->api()->getEnvironmentLabel($environment, 'error') . " can't be redeployed." + "Operation not available: The environment " . $this->api->getEnvironmentLabel($environment, 'error') . " can't be redeployed.", ); if (!$environment->isActive()) { @@ -37,19 +46,15 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - if (!$questionHelper->confirm('Are you sure you want to redeploy the environment ' . $environment->id . '?')) { + if (!$this->questionHelper->confirm('Are you sure you want to redeploy the environment ' . $environment->id . '?')) { return 1; } $result = $environment->runOperation('redeploy'); - if ($this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); - $success = $activityMonitor->waitMultiple($result->getActivities(), $this->getSelectedProject()); + if ($this->activityMonitor->shouldWait($input)) { + $activityMonitor = $this->activityMonitor; + $success = $activityMonitor->waitMultiple($result->getActivities(), $selection->getProject()); if (!$success) { return 1; } diff --git a/src/Command/Environment/EnvironmentRelationshipsCommand.php b/src/Command/Environment/EnvironmentRelationshipsCommand.php index bbb1938757..8b9712c40d 100644 --- a/src/Command/Environment/EnvironmentRelationshipsCommand.php +++ b/src/Command/Environment/EnvironmentRelationshipsCommand.php @@ -1,56 +1,60 @@ setName('environment:relationships') - ->setAliases(['relationships', 'rel']) - ->setDescription('Show an environment\'s relationships') ->addArgument('environment', InputArgument::OPTIONAL, 'The environment') ->addOption('property', 'P', InputOption::VALUE_REQUIRED, 'The relationship property to view') ->addOption('refresh', null, InputOption::VALUE_NONE, 'Whether to refresh the relationships'); - $this->addProjectOption() - ->addEnvironmentOption() - ->addAppOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->selector->addAppOption($this->getDefinition()); + $this->addCompleter($this->selector); Ssh::configureInput($this->getDefinition()); $this->addExample("View all the current environment's relationships"); $this->addExample("View the 'main' environment's relationships", 'main'); $this->addExample("View the 'main' environment's database port", 'main --property database.0.port'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - /** @var \Platformsh\Cli\Service\Relationships $relationshipsService */ - $relationshipsService = $this->getService('relationships'); - $this->chooseEnvFilter = $this->filterEnvsMaybeActive(); - $host = $this->selectHost($input, $relationshipsService->hasLocalEnvVar()); + $selection = $this->selector->getSelection($input, new SelectorConfig(allowLocalHost: $this->relationships->hasLocalEnvVar(), chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive())); + $host = $this->selector->getHostFromSelection($input, $selection); - $relationships = $relationshipsService->getRelationships($host, $input->getOption('refresh')); + $relationships = $this->relationships->getRelationships($host, $input->getOption('refresh')); foreach ($relationships as $name => $relationship) { foreach ($relationship as $index => $instance) { if (!isset($instance['url'])) { - $relationships[$name][$index]['url'] = $relationshipsService->buildUrl($instance); + $relationships[$name][$index]['url'] = $this->relationships->buildUrl($instance); } } } - - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - $formatter->displayData($output, $relationships, $input->getOption('property')); + $this->propertyFormatter->displayData($output, $relationships, $input->getOption('property')); return 0; } diff --git a/src/Command/Environment/EnvironmentResumeCommand.php b/src/Command/Environment/EnvironmentResumeCommand.php index e93b607f64..f6ce688d82 100644 --- a/src/Command/Environment/EnvironmentResumeCommand.php +++ b/src/Command/Environment/EnvironmentResumeCommand.php @@ -1,53 +1,59 @@ setName('environment:resume') - ->setDescription('Resume a paused environment'); - $this->addProjectOption() - ->addEnvironmentOption(); - $this->addWaitOptions(); + parent::__construct(); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function configure(): void { - $this->chooseEnvFilter = $this->filterEnvsByStatus(['paused']); - $this->validateInput($input); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); + } - $environment = $this->getSelectedEnvironment(); + protected function execute(InputInterface $input, OutputInterface $output): int + { + $selection = $this->selector->getSelection($input, new SelectorConfig(chooseEnvFilter: SelectorConfig::filterEnvsByStatus(['paused']))); + $environment = $selection->getEnvironment(); if (!$environment->operationAvailable('resume', true)) { if ($environment->status !== 'paused') { - $this->stdErr->writeln(sprintf('The environment %s is not paused. Only paused environments can be resumed.', $this->api()->getEnvironmentLabel($environment, 'comment'))); + $this->stdErr->writeln(sprintf('The environment %s is not paused. Only paused environments can be resumed.', $this->api->getEnvironmentLabel($environment, 'comment'))); } else { - $this->stdErr->writeln(sprintf("Operation not available: The environment %s can't be resumed.", $this->api()->getEnvironmentLabel($environment, 'error'))); + $this->stdErr->writeln(sprintf("Operation not available: The environment %s can't be resumed.", $this->api->getEnvironmentLabel($environment, 'error'))); } return 1; } - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - if (!$questionHelper->confirm('Are you sure you want to resume the paused environment ' . $environment->id . '?')) { + if (!$this->questionHelper->confirm('Are you sure you want to resume the paused environment ' . $environment->id . '?')) { return 1; } $result = $environment->runOperation('resume'); - $this->api()->clearEnvironmentsCache($environment->project); + $this->api->clearEnvironmentsCache($environment->project); - if ($this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); - $success = $activityMonitor->waitMultiple($result->getActivities(), $this->getSelectedProject()); + if ($this->activityMonitor->shouldWait($input)) { + $activityMonitor = $this->activityMonitor; + $success = $activityMonitor->waitMultiple($result->getActivities(), $selection->getProject()); if (!$success) { return 1; } diff --git a/src/Command/Environment/EnvironmentScpCommand.php b/src/Command/Environment/EnvironmentScpCommand.php index 6530b67a72..2bfbbd73aa 100644 --- a/src/Command/Environment/EnvironmentScpCommand.php +++ b/src/Command/Environment/EnvironmentScpCommand.php @@ -1,32 +1,40 @@ setName('environment:scp') - ->setAliases(['scp']) ->addArgument('files', InputArgument::IS_ARRAY, 'Files to copy. Use the remote: prefix to define remote locations.') - ->addOption('recursive', 'r', InputOption::VALUE_NONE, 'Recursively copy entire directories') - ->setDescription('Copy files to and from an environment using scp'); - $this->addProjectOption() - ->addEnvironmentOption() - ->addRemoteContainerOptions(); + ->addOption('recursive', 'r', InputOption::VALUE_NONE, 'Recursively copy entire directories'); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->selector->addRemoteContainerOptions($this->getDefinition()); + $this->addCompleter($this->selector); Ssh::configureInput($this->getDefinition()); $this->addExample('Copy local files a.txt and b.txt to remote mount var/files', "a.txt b.txt remote:var/files"); $this->addExample('Copy remote files c.txt to current directory', "remote:c.txt ."); @@ -34,25 +42,21 @@ protected function configure() $this->addExample('Copy files inside subdirectory dump/ to remote mount var/files', "-r dump/* remote:var/logs"); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $files = $input->getArgument('files'); if (!$files) { throw new InvalidArgumentException('No files specified'); } - $this->chooseEnvFilter = $this->filterEnvsMaybeActive(); - $this->validateInput($input); + $selection = $this->selector->getSelection($input, new SelectorConfig(chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive())); + $container = $selection->getRemoteContainer(); - $container = $this->selectRemoteContainer($input); $sshUrl = $container->getSshUrl($input->getOption('instance')); - - /** @var Ssh $ssh */ - $ssh = $this->getService('ssh'); $command = 'scp'; - if ($sshArgs = $ssh->getSshArgs($sshUrl)) { - $command .= ' ' . implode(' ', array_map([OsUtil::class, 'escapePosixShellArg'], $sshArgs)); + if ($sshArgs = $this->ssh->getSshArgs($sshUrl)) { + $command .= ' ' . implode(' ', array_map(OsUtil::escapePosixShellArg(...), $sshArgs)); } if ($input->getOption('recursive')) { @@ -67,11 +71,11 @@ protected function execute(InputInterface $input, OutputInterface $output) $remoteUsed = false; foreach ($files as $file) { - if (strpos($file, 'remote:') === 0) { - $command .= ' ' . escapeshellarg($sshUrl . ':' . substr($file, 7)); + if (str_starts_with((string) $file, 'remote:')) { + $command .= ' ' . escapeshellarg($sshUrl . ':' . substr((string) $file, 7)); $remoteUsed = true; } else { - $command .= ' ' . escapeshellarg($file); + $command .= ' ' . escapeshellarg((string) $file); } } @@ -79,15 +83,11 @@ protected function execute(InputInterface $input, OutputInterface $output) throw new InvalidArgumentException('At least one argument needs to contain the "remote:" prefix'); } - /** @var \Platformsh\Cli\Service\Shell $shell */ - $shell = $this->getService('shell'); - $start = \time(); - $exitCode = $shell->executeSimple($command); + $exitCode = $this->shell->executeSimple($command); if ($exitCode !== 0) { - /** @var \Platformsh\Cli\Service\SshDiagnostics $diagnostics */ - $diagnostics = $this->getService('ssh_diagnostics'); + $diagnostics = $this->sshDiagnostics; $diagnostics->diagnoseFailureWithTest($sshUrl, $start, $exitCode); } diff --git a/src/Command/Environment/EnvironmentSetRemoteCommand.php b/src/Command/Environment/EnvironmentSetRemoteCommand.php index 1fb9f96c86..eb0ba316d8 100644 --- a/src/Command/Environment/EnvironmentSetRemoteCommand.php +++ b/src/Command/Environment/EnvironmentSetRemoteCommand.php @@ -1,100 +1,107 @@ setName('environment:set-remote') - ->setDescription('Set the remote environment to map to a branch') ->addArgument( 'environment', InputArgument::REQUIRED, - 'The environment machine name. Set to 0 to remove the mapping for a branch' + 'The environment machine name. Set to 0 to remove the mapping for a branch', ) ->addArgument( 'branch', InputArgument::OPTIONAL, - 'The Git branch to map (defaults to the current branch)' + 'The Git branch to map (defaults to the current branch)', ); $this->addExample('Set the remote environment for this branch to "pr-655"', 'pr-655'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $project = $this->getCurrentProject(); + $project = $this->selector->getCurrentProject(); if (!$project) { throw new RootNotFoundException(); } - $projectRoot = $this->getProjectRoot(); - - /** @var \Platformsh\Cli\Service\Git $git */ - $git = $this->getService('git'); - $git->setDefaultRepositoryDir($projectRoot); + $projectRoot = (string) $this->selector->getProjectRoot(); + $this->git->setDefaultRepositoryDir($projectRoot); $specifiedEnvironmentId = $input->getArgument('environment'); - if ($specifiedEnvironmentId != '0' - && !($specifiedEnvironment = $this->api()->getEnvironment($specifiedEnvironmentId, $project))) { - $this->stdErr->writeln("Environment not found: $specifiedEnvironmentId"); - return 1; + $specifiedEnvironment = null; + if ($specifiedEnvironmentId != '0') { + $specifiedEnvironment = $this->api->getEnvironment($specifiedEnvironmentId, $project); + if (!$specifiedEnvironment) { + $this->stdErr->writeln("Environment not found: $specifiedEnvironmentId"); + return 1; + } } $specifiedBranch = $input->getArgument('branch'); if ($specifiedBranch) { - if (!$git->branchExists($specifiedBranch)) { + if (!$this->git->branchExists($specifiedBranch)) { $this->stdErr->writeln("Branch not found: $specifiedBranch"); return 1; } } else { - $specifiedBranch = $git->getCurrentBranch(); + $specifiedBranch = $this->git->getCurrentBranch(); } // Check whether the branch is mapped by default (its name or its Git // upstream is the same as the remote environment ID). - $mappedByDefault = isset($specifiedEnvironment) + $mappedByDefault = $specifiedEnvironment && $specifiedEnvironment->status != 'inactive' && $specifiedEnvironmentId === $specifiedBranch; if ($specifiedEnvironmentId != '0' && !$mappedByDefault) { - $upstream = $git->getUpstream($specifiedBranch); - if (strpos($upstream, '/')) { - list(, $upstream) = explode('/', $upstream, 2); + $upstream = $this->git->getUpstream($specifiedBranch); + if (strpos((string) $upstream, '/')) { + [, $upstream] = explode('/', (string) $upstream, 2); } if ($upstream === $specifiedEnvironmentId) { $mappedByDefault = true; } - if (!$mappedByDefault && $git->branchExists($specifiedEnvironmentId)) { + if (!$mappedByDefault && $this->git->branchExists($specifiedEnvironmentId)) { $this->stdErr->writeln( - "A local branch already exists named $specifiedEnvironmentId" + "A local branch already exists named $specifiedEnvironmentId", ); } } - - // Perform the mapping or unmapping. - /** @var \Platformsh\Cli\Local\LocalProject $localProject */ - $localProject = $this->getService('local.project'); - $projectConfig = $localProject->getProjectConfig($projectRoot); + $projectConfig = $this->localProject->getProjectConfig($projectRoot); $projectConfig += ['mapping' => []]; if ($mappedByDefault || $specifiedEnvironmentId == '0') { unset($projectConfig['mapping'][$specifiedBranch]); - $localProject->writeCurrentProjectConfig($projectConfig, $projectRoot); + $this->localProject->writeCurrentProjectConfig($projectConfig, $projectRoot); } else { if (isset($projectConfig['mapping']) && ($current = array_search($specifiedEnvironmentId, $projectConfig['mapping'])) !== false) { unset($projectConfig['mapping'][$current]); } $projectConfig['mapping'][$specifiedBranch] = $specifiedEnvironmentId; - $localProject->writeCurrentProjectConfig($projectConfig, $projectRoot); + $this->localProject->writeCurrentProjectConfig($projectConfig, $projectRoot); } // Check the success of the operation. @@ -103,19 +110,19 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(sprintf( 'The local branch %s is mapped to the remote environment %s', $specifiedBranch, - $actualRemoteEnvironment + $actualRemoteEnvironment, )); } elseif ($mappedByDefault) { $actualRemoteEnvironment = $specifiedBranch; $this->stdErr->writeln(sprintf( 'The local branch %s is mapped to the default remote environment, %s', $specifiedBranch, - $actualRemoteEnvironment + $actualRemoteEnvironment, )); } else { $this->stdErr->writeln(sprintf( 'The local branch %s is not mapped to a remote environment', - $specifiedBranch + $specifiedBranch, )); } diff --git a/src/Command/Environment/EnvironmentSshCommand.php b/src/Command/Environment/EnvironmentSshCommand.php index cfe5b6d27b..1298eef073 100644 --- a/src/Command/Environment/EnvironmentSshCommand.php +++ b/src/Command/Environment/EnvironmentSshCommand.php @@ -1,65 +1,74 @@ setName('environment:ssh') - ->setAliases(['ssh']) ->addArgument('cmd', InputArgument::OPTIONAL | InputArgument::IS_ARRAY, 'A command to run on the environment.') ->addOption('pipe', null, InputOption::VALUE_NONE, 'Output the SSH URL only.') ->addOption('all', null, InputOption::VALUE_NONE, 'Output all SSH URLs (for every app).') - ->addOption('option', 'o', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Pass an extra option to SSH') - ->setDescription('SSH to the current environment'); - $this->addProjectOption() - ->addEnvironmentOption() - ->addRemoteContainerOptions(); + ->addOption('option', 'o', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Pass an extra option to SSH'); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->selector->addRemoteContainerOptions($this->getDefinition()); + $this->addCompleter($this->selector); Ssh::configureInput($this->getDefinition()); $this->addExample('Open a shell over SSH'); $this->addExample('Pass an extra option to SSH', "-o 'RequestTTY force'"); $this->addExample('List files', 'ls'); $this->addExample("Monitor the app log (use '--' before flags)", 'tail /var/log/app.log -- -n50 -f'); - $envPrefix = $this->config()->get('service.env_prefix'); + $envPrefix = $this->config->getStr('service.env_prefix'); $this->addExample('Display relationships (use quotes for complex syntax)', "'echo \${$envPrefix}RELATIONSHIPS | base64 --decode'"); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->chooseEnvFilter = $this->filterEnvsMaybeActive(); - $this->validateInput($input); - $environment = $this->getSelectedEnvironment(); + try { + $selection = $this->selector->getSelection($input, new SelectorConfig(chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive())); + $environment = $selection->getEnvironment(); - if ($input->getOption('all')) { - $output->writeln(array_values($environment->getSshUrls())); + if ($input->getOption('all')) { + $output->writeln(array_values($environment->getSshUrls())); - return 0; - } + return 0; + } + + $container = $selection->getRemoteContainer(); - try { - $container = $this->selectRemoteContainer($input); $sshUrl = $container->getSshUrl($input->getOption('instance')); } catch (EnvironmentStateException $e) { - if ($e->getEnvironment()->id !== $environment->id) { - throw $e; - } - switch ($e->getEnvironment()->status) { + $environment = $e->getEnvironment(); + switch ($environment->status) { case 'inactive': - $this->stdErr->writeln(sprintf('The environment %s is inactive, so an SSH connection is not possible.', $this->api()->getEnvironmentLabel($e->getEnvironment(), 'error'))); + $this->stdErr->writeln(sprintf('The environment %s is inactive, so an SSH connection is not possible.', $this->api->getEnvironmentLabel($e->getEnvironment(), 'error'))); if (!$e->getEnvironment()->has_code) { $this->stdErr->writeln(''); $this->stdErr->writeln('Push code to the environment to activate it.'); @@ -67,10 +76,10 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; case 'paused': - $this->stdErr->writeln(sprintf('The environment %s is paused, so an SSH connection is not possible.', $this->api()->getEnvironmentLabel($e->getEnvironment(), 'error'))); - if ($this->config()->isCommandEnabled('environment:resume')) { + $this->stdErr->writeln(sprintf('The environment %s is paused, so an SSH connection is not possible.', $this->api->getEnvironmentLabel($e->getEnvironment(), 'error'))); + if ($this->config->isCommandEnabled('environment:resume')) { $this->stdErr->writeln(''); - $this->stdErr->writeln(sprintf('Resume the environment by running: %s environment:resume -e %s', $this->config()->get('application.executable'), OsUtil::escapeShellArg($environment->id))); + $this->stdErr->writeln(sprintf('Resume the environment by running: %s environment:resume -e %s', $this->config->getStr('application.executable'), OsUtil::escapeShellArg($environment->id))); } return 1; } @@ -89,26 +98,19 @@ protected function execute(InputInterface $input, OutputInterface $output) if (empty($remoteCommand) && $this->runningViaMulti) { throw new InvalidArgumentException('The cmd argument is required when running via "multi"'); } - - /** @var \Platformsh\Cli\Service\Ssh $ssh */ - $ssh = $this->getService('ssh'); - $command = $ssh->getSshCommand($sshUrl, $input->getOption('option'), $remoteCommand); - - /** @var \Platformsh\Cli\Service\Shell $shell */ - $shell = $this->getService('shell'); + $command = $this->ssh->getSshCommand($sshUrl, $input->getOption('option'), $remoteCommand); $start = \time(); - $exitCode = $shell->executeSimple($command, null, $ssh->getEnv()); + $exitCode = $this->shell->executeSimple($command, null, $this->ssh->getEnv()); if ($exitCode !== 0) { - if ($this->getSelectedProject()->isSuspended()) { + if ($selection->getProject()->isSuspended()) { $this->stdErr->writeln(''); - $this->warnIfSuspended($this->getSelectedProject()); + $this->api->warnIfSuspended($selection->getProject()); return $exitCode; } - /** @var \Platformsh\Cli\Service\SshDiagnostics $diagnostics */ - $diagnostics = $this->getService('ssh_diagnostics'); + $diagnostics = $this->sshDiagnostics; $diagnostics->diagnoseFailureWithTest($sshUrl, $start, $exitCode); } diff --git a/src/Command/Environment/EnvironmentSynchronizeCommand.php b/src/Command/Environment/EnvironmentSynchronizeCommand.php index 377239871c..203099c75e 100644 --- a/src/Command/Environment/EnvironmentSynchronizeCommand.php +++ b/src/Command/Environment/EnvironmentSynchronizeCommand.php @@ -1,73 +1,83 @@ setName('environment:synchronize') - ->setAliases(['sync']); - if ($this->config()->get('api.sizing')) { + if ($this->config->getBool('api.sizing')) { $this->setDescription("Synchronize an environment's code, data and/or resources from its parent"); - $this->addArgument('synchronize', InputArgument::IS_ARRAY, 'List what to synchronize: "code", "data", and/or "resources".'); + $this->addArgument('synchronize', InputArgument::IS_ARRAY, 'List what to synchronize: "code", "data", and/or "resources".', null, ['code', 'data', 'resources']); } else { $this->setDescription("Synchronize an environment's code and/or data from its parent"); - $this->addArgument('synchronize', InputArgument::IS_ARRAY, 'What to synchronize: "code", "data" or both'); + $this->addArgument('synchronize', InputArgument::IS_ARRAY, 'What to synchronize: "code", "data" or both', null, ['code', 'data', 'both']); } $this->addOption('rebase', null, InputOption::VALUE_NONE, 'Synchronize code by rebasing instead of merging'); - $this->addProjectOption() - ->addEnvironmentOption() - ->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); $this->addExample('Synchronize data from the parent environment', 'data'); $this->addExample('Synchronize code and data from the parent environment', 'code data'); $help = <<config()->get('api.sizing')) { + Synchronizing "data" means that all files in all services (including + static files, databases, logs, search indices, etc.) will be copied from the + parent to the child. + EOT; + if ($this->config->getBool('api.sizing')) { $help .= "\n\n" . <<addExample('Synchronize code, data and resources from the parent environment', 'code data resources'); } $this->setHelp($help); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->chooseEnvFilter = $this->filterEnvsMaybeActive(); - $this->validateInput($input); + $selection = $this->selector->getSelection($input, new SelectorConfig(chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive())); - $selectedEnvironment = $this->getSelectedEnvironment(); + $selectedEnvironment = $selection->getEnvironment(); $environmentId = $selectedEnvironment->id; $parentId = $selectedEnvironment->parent; if (!$selectedEnvironment->operationAvailable('synchronize', true)) { $this->stdErr->writeln( - "Operation not available: The environment $environmentId can't be synchronized." + "Operation not available: The environment $environmentId can't be synchronized.", ); if ($selectedEnvironment->parent === null) { @@ -77,7 +87,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } elseif (!$selectedEnvironment->isActive()) { $this->stdErr->writeln('The environment is not active.'); } else { - $parentEnvironment = $this->api()->getEnvironment($parentId, $this->getSelectedProject(), false); + $parentEnvironment = $this->api->getEnvironment($parentId, $selection->getProject(), false); if ($parentEnvironment && !$parentEnvironment->isActive()) { $this->stdErr->writeln(sprintf('The parent environment %s is not active.', $parentId)); } @@ -86,21 +96,18 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $rebase = (bool) $input->getOption('rebase'); $integrationManagingCode = null; if ($selectedEnvironment->getProperty('has_remote', false)) { - $integration = $this->api()->getCodeSourceIntegration($this->getSelectedProject()); + $integration = $this->api->getCodeSourceIntegration($selection->getProject()); if ($integration && $integration->getProperty('fetch_branches') === true) { $integrationManagingCode = $integration; } } if ($synchronize = $input->getArgument('synchronize')) { - $validOptions = $this->config()->get('api.sizing') ? ['code', 'data', 'resources'] : ['code', 'data', 'both']; + $validOptions = $this->config->getBool('api.sizing') ? ['code', 'data', 'resources'] : ['code', 'data', 'both']; $toSync = []; foreach ($synchronize as $item) { if (!in_array($item, $validOptions)) { @@ -117,14 +124,14 @@ protected function execute(InputInterface $input, OutputInterface $output) if (in_array('code', $toSync) && $integrationManagingCode) { $this->stdErr->writeln(sprintf("Code cannot be synchronized as it is managed by the project's %s integration.", $integrationManagingCode->type)); - if ($this->config()->isCommandEnabled('integration:get')) { + if ($this->config->isCommandEnabled('integration:get')) { $this->stdErr->writeln(''); - $this->stdErr->writeln(sprintf('To view the integration, run: %s integration:get %s', $this->config()->get('application.executable'), OsUtil::escapeShellArg($integrationManagingCode->id))); + $this->stdErr->writeln(sprintf('To view the integration, run: %s integration:get %s', $this->config->getStr('application.executable'), OsUtil::escapeShellArg($integrationManagingCode->id))); } return 1; } - if (in_array('resources', $toSync) && !$this->api()->supportsSizingApi($this->getSelectedProject())) { + if (in_array('resources', $toSync) && !$this->api->supportsSizingApi($selection->getProject())) { $this->stdErr->writeln('Resources cannot be synchronized as the project does not support flexible resources.'); return 1; } @@ -138,9 +145,9 @@ protected function execute(InputInterface $input, OutputInterface $output) 'Are you sure you want to synchronize %s from %s to %s?', StringUtil::formatItemList($toSync, '', '', ' and '), $parentId, - $environmentId + $environmentId, ); - if (!$questionHelper->confirm($confirmText)) { + if (!$this->questionHelper->confirm($confirmText)) { return 1; } $this->stdErr->writeln(''); @@ -148,17 +155,17 @@ protected function execute(InputInterface $input, OutputInterface $output) $toSync = []; if (!$integrationManagingCode) { - $syncCode = $questionHelper->confirm( + $syncCode = $this->questionHelper->confirm( "Do you want to synchronize code from $parentId to $environmentId?", - false + false, ); if ($syncCode) { $toSync[] = 'code'; if (!$rebase) { - $rebase = $questionHelper->confirm( + $rebase = $this->questionHelper->confirm( "Do you want to synchronize code by rebasing instead of merging?", - false + false, ); } } elseif ($rebase) { @@ -168,19 +175,19 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(''); } - if ($questionHelper->confirm( + if ($this->questionHelper->confirm( "Do you want to synchronize data from $parentId to $environmentId?", - false + false, )) { $toSync[] = 'data'; } $this->stdErr->writeln(''); - if ($this->config()->get('api.sizing') && $this->api()->supportsSizingApi($this->getSelectedProject())) { - if ($questionHelper->confirm( + if ($this->config->getBool('api.sizing') && $this->api->supportsSizingApi($selection->getProject())) { + if ($this->questionHelper->confirm( "Do you want to synchronize resources from $parentId to $environmentId?", - false + false, )) { $toSync[] = 'resources'; } @@ -209,7 +216,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $result = $selectedEnvironment->runOperation('synchronize', 'POST', $params); } catch (BadResponseException $e) { // Translate validation error messages. - if (($response = $e->getResponse()) && $response->getStatusCode() === 400 && ($body = $response->getBody())) { + if ($e->getResponse()->getStatusCode() === 400 && ($body = $e->getResponse()->getBody())) { $data = \json_decode((string) $body, true); if (\is_array($data) && !empty($data['detail']['error'])) { $this->stdErr->writeln(''); @@ -219,10 +226,9 @@ protected function execute(InputInterface $input, OutputInterface $output) } throw $e; } - if ($this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); - $success = $activityMonitor->waitMultiple($result->getActivities(), $this->getSelectedProject()); + if ($this->activityMonitor->shouldWait($input)) { + $activityMonitor = $this->activityMonitor; + $success = $activityMonitor->waitMultiple($result->getActivities(), $selection->getProject()); if (!$success) { return 1; } @@ -230,24 +236,4 @@ protected function execute(InputInterface $input, OutputInterface $output) return 0; } - - /** - * {@inheritdoc} - */ - public function completeArgumentValues($argumentName, CompletionContext $context) - { - if ($argumentName === 'synchronize') { - return $this->config()->get('api.sizing') ? ['code', 'data', 'resources'] : ['code', 'data', 'both']; - } - - return []; - } - - /** - * {@inheritdoc} - */ - public function completeOptionValues($optionName, CompletionContext $context) - { - return []; - } } diff --git a/src/Command/Environment/EnvironmentUrlCommand.php b/src/Command/Environment/EnvironmentUrlCommand.php index 2fbd6f322a..9d98a708b2 100644 --- a/src/Command/Environment/EnvironmentUrlCommand.php +++ b/src/Command/Environment/EnvironmentUrlCommand.php @@ -1,49 +1,60 @@ setName('environment:url') - ->setAliases(['url']) - ->setDescription('Get the public URLs of an environment') ->addOption('primary', '1', InputOption::VALUE_NONE, 'Only return the URL for the primary route'); Url::configureInput($this->getDefinition()); - $this->addProjectOption() - ->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); $this->addExample('Give a choice of URLs to open (or print all URLs if there is no browser)'); $this->addExample('Print all URLs', '--pipe'); $this->addExample('Print and/or open the primary route URL', '--primary'); $this->addExample('Print the primary route URL', '--primary --pipe'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { // Allow override via PLATFORM_ROUTES. - $prefix = $this->config()->get('service.env_prefix'); + $prefix = $this->config->getStr('service.env_prefix'); if (getenv($prefix . 'ROUTES') && !LocalHost::conflictsWithCommandLineOptions($input, $prefix)) { - $this->debug('Reading URLs from environment variable ' . $prefix . 'ROUTES'); - $decoded = json_decode(base64_decode(getenv($prefix . 'ROUTES'), true), true); + $this->io->debug('Reading URLs from environment variable ' . $prefix . 'ROUTES'); + $decoded = json_decode((string) base64_decode(getenv($prefix . 'ROUTES'), true), true); if (empty($decoded)) { throw new \RuntimeException('Failed to decode: ' . $prefix . 'ROUTES'); } $routes = Route::fromVariables($decoded); } else { - $this->debug('Reading URLs from the API'); - $this->chooseEnvFilter = $this->filterEnvsMaybeActive(); - $this->validateInput($input); - $deployment = $this->api()->getCurrentDeployment($this->getSelectedEnvironment()); + $this->io->debug('Reading URLs from the API'); + $selection = $this->selector->getSelection($input, new SelectorConfig(chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive())); + $deployment = $this->api->getCurrentDeployment($selection->getEnvironment()); $routes = Route::fromDeploymentApi($deployment->routes); } if (empty($routes)) { @@ -68,9 +79,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } // Build a list of all the route URLs. - $urls = array_map(function (Route $route) { - return $route->url; - }, $routes); + $urls = array_map(fn(Route $route) => $route->url, $routes); $this->displayOrOpenUrls($urls, $input, $output); @@ -80,11 +89,11 @@ protected function execute(InputInterface $input, OutputInterface $output) /** * Displays or opens URLs. * - * @param string[] $urls - * @param \Symfony\Component\Console\Input\InputInterface $input - * @param \Symfony\Component\Console\Output\OutputInterface $output + * @param string[] $urls + * @param InputInterface $input + * @param OutputInterface $output */ - private function displayOrOpenUrls(array $urls, InputInterface $input, OutputInterface $output) + private function displayOrOpenUrls(array $urls, InputInterface $input, OutputInterface $output): void { // Just display the URLs if --browser is 0 or if --pipe is set. if ($input->getOption('pipe') || $input->getOption('browser') === '0') { @@ -99,14 +108,12 @@ private function displayOrOpenUrls(array $urls, InputInterface $input, OutputInt // non-interactive input. $toDisplay = $urls[0]; } - /** @var \Platformsh\Cli\Service\Url $urlService */ - $urlService = $this->getService('url'); - if (!$urlService->hasDisplay()) { - $this->debug('Not opening URLs (no display found)'); + if (!$this->url->hasDisplay()) { + $this->io->debug('Not opening URLs (no display found)'); $output->writeln($toDisplay); return; - } elseif (!$urlService->canOpenUrls()) { - $this->debug('Not opening URLs (no browser found)'); + } elseif (!$this->url->canOpenUrls()) { + $this->io->debug('Not opening URLs (no browser found)'); $output->writeln($toDisplay); return; } @@ -115,12 +122,10 @@ private function displayOrOpenUrls(array $urls, InputInterface $input, OutputInt if (count($urls) === 1) { $url = $urls[0]; } else { - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $url = $questionHelper->choose(array_combine($urls, $urls), 'Enter a number to open a URL', $urls[0]); + $url = $this->questionHelper->choose(array_combine($urls, $urls), 'Enter a number to open a URL', $urls[0]); } - $urlService->openUrl($url); + $this->url->openUrl($url); } /** @@ -130,7 +135,7 @@ private function displayOrOpenUrls(array $urls, InputInterface $input, OutputInt * * @return string|null */ - private function findPrimaryRouteUrl(array $routes) + private function findPrimaryRouteUrl(array $routes): ?string { foreach ($routes as $route) { if ($route->primary) { diff --git a/src/Command/Environment/EnvironmentXdebugCommand.php b/src/Command/Environment/EnvironmentXdebugCommand.php index 37ba58cefb..382daa9640 100644 --- a/src/Command/Environment/EnvironmentXdebugCommand.php +++ b/src/Command/Environment/EnvironmentXdebugCommand.php @@ -1,46 +1,54 @@ setName('environment:xdebug') - ->setAliases(['xdebug']) - ->addOption('port', null, InputArgument::OPTIONAL, 'The local port', 9000) - ->setDescription('Open a tunnel to Xdebug on the environment'); - $this->addProjectOption() - ->addEnvironmentOption() - ->addRemoteContainerOptions(); + $this->addOption('port', null, InputArgument::OPTIONAL, 'The local port', 9000); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->selector->addRemoteContainerOptions($this->getDefinition()); + $this->addCompleter($this->selector); Ssh::configureInput($this->getDefinition()); $this->addExample('Connect to Xdebug on the environment, listening locally on port 9000.'); } - public function isHidden() + public function isHidden(): bool { if (parent::isHidden()) { return true; } // Hide this command in the list if the project is not PHP. - $projectRoot = $this->getProjectRoot(); + $projectRoot = $this->selector->getProjectRoot(); if ($projectRoot) { try { return !$this->isPhp($projectRoot); - } catch (\Exception $e) { + } catch (\Exception) { // Ignore errors when loading or parsing configuration. return true; } @@ -51,20 +59,16 @@ public function isHidden() /** * Checks if a project contains a PHP app. - * - * @param string $directory - * - * @return bool */ - private function isPhp($directory) { + private function isPhp(string $directory): bool + { static $isPhp; if (!isset($isPhp)) { $isPhp = false; - /** @var \Platformsh\Cli\Local\ApplicationFinder $finder */ - $finder = $this->getService('app_finder'); + $finder = $this->applicationFinder; foreach ($finder->findApplications($directory) as $app) { $type = $app->getType(); - if ($type === 'php' || strpos($type, 'php:') === 0) { + if ($type === 'php' || str_starts_with((string) $type, 'php:')) { $isPhp = true; break; } @@ -74,25 +78,23 @@ private function isPhp($directory) { return $isPhp; } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->chooseEnvFilter = $this->filterEnvsMaybeActive(); - $this->validateInput($input); - $this->getSelectedEnvironment(); + $selection = $this->selector->getSelection($input, new SelectorConfig(chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive())); - $container = $this->selectRemoteContainer($input); + $container = $selection->getRemoteContainer(); $sshUrl = $container->getSshUrl($input->getOption('instance')); $config = $container->getConfig()->getNormalized(); - $ideKey = isset($config['runtime']['xdebug']['idekey']) ? $config['runtime']['xdebug']['idekey'] : ''; + $ideKey = $config['runtime']['xdebug']['idekey'] ?? ''; if (!$ideKey) { $this->stdErr->writeln('No IDE key found.'); $this->stdErr->writeln(''); $this->stdErr->writeln('To use Xdebug your project must have an idekey value set.'); $this->stdErr->writeln(''); - if ($this->config()->has('service.app_config_file')) { - $this->stdErr->writeln(sprintf('Set this in the %s file as in this example:', $this->config()->get('service.app_config_file'))); + if ($this->config->has('service.app_config_file')) { + $this->stdErr->writeln(sprintf('Set this in the %s file as in this example:', $this->config->getStr('service.app_config_file'))); } else { $this->stdErr->writeln('Set this in the application configuration file as in this example:'); } @@ -100,20 +102,16 @@ protected function execute(InputInterface $input, OutputInterface $output) "\n# ...\n" . "runtime:\n" . " xdebug:\n" - . " idekey: secret_key" + . " idekey: secret_key", ); return 1; } - - /** @var Ssh $ssh */ - $ssh = $this->getService('ssh'); - // The socket is removed to prevent 'file already exists' errors. - $commandCleanup = $ssh->getSshCommand($sshUrl, [], 'rm -rf ' . self::SOCKET_PATH); - $this->debug("Cleanup command: " . $commandCleanup); - $process = new Process($commandCleanup, null, $ssh->getEnv()); + $commandCleanup = $this->ssh->getSshCommand($sshUrl, [], 'rm -rf ' . self::SOCKET_PATH); + $this->io->debug("Cleanup command: " . $commandCleanup); + $process = Process::fromShellCommandline($commandCleanup, null, $this->ssh->getEnv()); $process->run(); $this->stdErr->writeln("Opening a local tunnel for Xdebug."); @@ -124,9 +122,9 @@ protected function execute(InputInterface $input, OutputInterface $output) $sshOptions = ['ExitOnForwardFailure yes', 'SessionType none', 'RequestTTY no']; $listenAddress = '127.0.0.1:' . $port; - $commandTunnel = $ssh->getSshCommand($sshUrl, $sshOptions) . ' -R ' . escapeshellarg(self::SOCKET_PATH . ':' . $listenAddress); - $this->debug("Tunnel command: " . $commandTunnel); - $process = new Process($commandTunnel, null, $ssh->getEnv()); + $commandTunnel = $this->ssh->getSshCommand($sshUrl, $sshOptions) . ' -R ' . escapeshellarg(self::SOCKET_PATH . ':' . $listenAddress); + $this->io->debug("Tunnel command: " . $commandTunnel); + $process = Process::fromShellCommandline($commandTunnel, null, $this->ssh->getEnv()); $process->setTimeout(null); $process->start(); @@ -143,7 +141,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(''); $this->stdErr->writeln( "To start debugging, set a cookie like 'XDEBUG_SESSION=$ideKey'" - . " or append 'XDEBUG_SESSION_START=$ideKey' in the URL query string when visiting your project." + . " or append 'XDEBUG_SESSION_START=$ideKey' in the URL query string when visiting your project.", ); $this->stdErr->writeln(''); $this->stdErr->writeln('To close the tunnel, quit this command by pressing Ctrl+C.'); diff --git a/src/Command/HasExamplesTrait.php b/src/Command/HasExamplesTrait.php deleted file mode 100644 index 3f07ab6c2c..0000000000 --- a/src/Command/HasExamplesTrait.php +++ /dev/null @@ -1,30 +0,0 @@ -examples[] = ['commandline' => $commandline, 'description' => $description]; - - return $this; - } - - /** - * @return array - */ - public function getExamples() - { - return $this->examples; - } -} diff --git a/src/Command/HelpCommand.php b/src/Command/HelpCommand.php index 0b096c662d..d77143aa41 100644 --- a/src/Command/HelpCommand.php +++ b/src/Command/HelpCommand.php @@ -1,4 +1,7 @@ config); + } - public function setCommand(Command $command) + public function setCommand(Command $command): void { $this->command = $command; } - /** - * {@inheritdoc} - */ - protected function configure() + protected function configure(): void { $this->ignoreValidationErrors(); - $this - ->setName('help') + $this->setName('help') + ->setDescription('Displays help for a command') ->setDefinition([ new InputArgument('command_name', InputArgument::OPTIONAL, 'The command name', 'help'), new InputOption('format', null, InputOption::VALUE_REQUIRED, 'The output format (txt, json, or md)', 'txt'), new InputOption('raw', null, InputOption::VALUE_NONE, 'To output raw command help'), ]) - ->setDescription('Displays help for a command') - ->setHelp(<<<'EOF' -The %command.name% command displays help for a given command: + ->setHelp( + <<<'EOF' + The %command.name% command displays help for a given command: - %command.full_name% list + %command.full_name% list -You can also output the help in other formats by using the --format option: + You can also output the help in other formats by using the --format option: - %command.full_name% --format=json list + %command.full_name% --format=json list -To display the list of available commands, please use the list command. -EOF - ) - ; + To display the list of available commands, please use the list command. + EOF, + ); } - /** - * @inheritdoc - */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - if (null === $this->command) { - $this->command = $this->getApplication() - ->find($input->getArgument('command_name')); - } - - $config = new Config(); + $command = $this->command ?: $this->getApplication()->find($input->getArgument('command_name')); $format = $input->getOption('format'); - $stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; $options = ['format' => $format, 'raw_text' => $input->getOption('raw'), 'all' => true]; switch ($format) { - case 'xml': - $stdErr->writeln('DEPRECATED The xml help format is deprecated and will be removed in a future version.'); - if (!extension_loaded('simplexml')) { - $stdErr->writeln('It depends on the simplexml PHP extension which is not installed.'); - return 1; - } - $stdErr->writeln(''); - (new XmlDescriptor())->describe($output, $this->command, $options); - return 0; case 'md': - (new CustomMarkdownDescriptor())->describe($output, $this->command, $options); + (new CustomMarkdownDescriptor($this->config->getStr('application.executable')))->describe($output, $command, $options); return 0; case 'json': - (new CustomJsonDescriptor())->describe($output, $this->command, $options); + (new CustomJsonDescriptor())->describe($output, $command, $options); return 0; case 'txt': - (new CustomTextDescriptor($config->get('application.executable')))->describe($output, $this->command, $options); + (new CustomTextDescriptor($this->config->getStr('application.executable')))->describe($output, $command, $options); return 0; } @@ -101,7 +85,7 @@ protected function execute(InputInterface $input, OutputInterface $output) // If the --format is unrecognised, it might be because the // command has its own --format option. Fall back to plain text // help. - (new CustomTextDescriptor($config->get('application.executable')))->describe($output, $this->command, $options); + (new CustomTextDescriptor($this->config->getStr('application.executable')))->describe($output, $command, $options); return 0; } diff --git a/src/Command/Integration/Activity/IntegrationActivityGetCommand.php b/src/Command/Integration/Activity/IntegrationActivityGetCommand.php index 92c95ef6f2..5db76a4eb6 100644 --- a/src/Command/Integration/Activity/IntegrationActivityGetCommand.php +++ b/src/Command/Integration/Activity/IntegrationActivityGetCommand.php @@ -1,40 +1,51 @@ setName('integration:activity:get') ->addArgument('integration', InputArgument::OPTIONAL, 'An integration ID. Leave blank to choose from a list.') ->addArgument('activity', InputArgument::OPTIONAL, 'The activity ID. Defaults to the most recent integration activity.') - ->addOption('property', 'P', InputOption::VALUE_REQUIRED, 'The property to view') - ->setDescription('View detailed information on a single integration activity'); - $this->addProjectOption(); + ->addOption('property', 'P', InputOption::VALUE_REQUIRED, 'The property to view'); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); $this->addOption('environment', 'e', InputOption::VALUE_REQUIRED, '[Deprecated option, not used]'); Table::configureInput($this->getDefinition()); PropertyFormatter::configureInput($this->getDefinition()); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->warnAboutDeprecatedOptions(['environment']); - $this->validateInput($input, true); + $this->io->warnAboutDeprecatedOptions(['environment']); + $selection = $this->selector->getSelection($input, new SelectorConfig(envRequired: false)); - $project = $this->getSelectedProject(); + $project = $selection->getProject(); $integration = $this->selectIntegration($project, $input->getArgument('integration'), $input->isInteractive()); if (!$integration) { @@ -45,12 +56,7 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($id) { $activity = $project->getActivity($id); if (!$activity) { - $activity = $this->api()->matchPartialId($id, $integration->getActivities(), 'Activity'); - if (!$activity) { - $this->stdErr->writeln("Integration activity not found: $id"); - - return 1; - } + $activity = $this->api->matchPartialId($id, $integration->getActivities(), 'Activity'); } } else { $activities = $integration->getActivities(); @@ -62,14 +68,10 @@ protected function execute(InputInterface $input, OutputInterface $output) } } - /** @var Table $table */ - $table = $this->getService('table'); - /** @var PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - + /** @var \Platformsh\Client\Model\Activity $activity */ $properties = $activity->getProperties(); - if (!$input->getOption('property') && !$table->formatIsMachineReadable()) { + if (!$input->getOption('property') && !$this->table->formatIsMachineReadable()) { $properties['description'] = ActivityMonitor::getFormattedDescription($activity, true); } else { $properties['description'] = $activity->description; @@ -81,7 +83,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } if ($property = $input->getOption('property')) { - $formatter->displayData($output, $properties, $property); + $this->propertyFormatter->displayData($output, $properties, $property); return 0; } @@ -92,31 +94,31 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln( 'The payload property has been omitted for brevity.' . ' You can still view it with the -P (--property) option.', - OutputInterface::VERBOSITY_VERBOSE + OutputInterface::VERBOSITY_VERBOSE, ); $header = []; $rows = []; foreach ($properties as $property => $value) { $header[] = $property; - $rows[] = $formatter->format($value, $property); + $rows[] = $this->propertyFormatter->format($value, $property); } - $table->renderSimple($rows, $header); + $this->table->renderSimple($rows, $header); - if (!$table->formatIsMachineReadable()) { - $executable = $this->config()->get('application.executable'); + if (!$this->table->formatIsMachineReadable()) { + $executable = $this->config->getStr('application.executable'); $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf( 'To view the log for this activity, run: %s integration:activity:log %s %s', $executable, $integration->id, - $activity->id + $activity->id, )); $this->stdErr->writeln(sprintf( 'To list activities for this integration, run: %s integration:activities %s', $executable, - $integration->id + $integration->id, )); } diff --git a/src/Command/Integration/Activity/IntegrationActivityListCommand.php b/src/Command/Integration/Activity/IntegrationActivityListCommand.php index 9aff4f59d6..c7c5116dc1 100644 --- a/src/Command/Integration/Activity/IntegrationActivityListCommand.php +++ b/src/Command/Integration/Activity/IntegrationActivityListCommand.php @@ -1,21 +1,32 @@ */ + private array $tableHeader = [ 'id' => 'ID', 'created' => 'Created', 'completed' => 'Completed', @@ -29,78 +40,76 @@ class IntegrationActivityListCommand extends IntegrationCommandBase 'time_build' => 'Build time (s)', 'time_deploy' => 'Deploy time (s)', ]; - private $defaultColumns = ['id', 'created', 'description', 'type', 'state', 'result']; + /** @var string[] */ + private array $defaultColumns = ['id', 'created', 'description', 'type', 'state', 'result']; + public function __construct(private readonly ActivityLoader $activityLoader, private readonly Api $api, private readonly Config $config, private readonly Io $io, private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table) + { + parent::__construct(); + } - /** - * {@inheritdoc} - */ - protected function configure() + protected function configure(): void { $this - ->setName('integration:activity:list') - ->setAliases(['integration:activities']) ->setHiddenAliases(['int:act', 'i:act']) ->addArgument('id', InputArgument::OPTIONAL, 'An integration ID. Leave blank to choose from a list.') - ->addOption('type', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + ->addOption( + 'type', + null, + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter activities by type.' - . "\n" . ArrayArgument::SPLIT_HELP + . "\n" . ArrayArgument::SPLIT_HELP, ) - ->addOption('exclude-type', 'x', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + ->addOption( + 'exclude-type', + 'x', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Exclude activities by type.' . "\n" . ArrayArgument::SPLIT_HELP - . "\nThe % or * characters can be used as a wildcard to exclude types." + . "\nThe % or * characters can be used as a wildcard to exclude types.", ) ->addOption('limit', null, InputOption::VALUE_REQUIRED, 'Limit the number of results displayed', 10) ->addOption('start', null, InputOption::VALUE_REQUIRED, 'Only activities created before this date will be listed') ->addOption('state', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter activities by state.' . "\n" . ArrayArgument::SPLIT_HELP) ->addOption('result', null, InputOption::VALUE_REQUIRED, 'Filter activities by result') - ->addOption('incomplete', 'i', InputOption::VALUE_NONE, 'Only list incomplete activities') - ->setDescription('Get a list of activities for an integration'); + ->addOption('incomplete', 'i', InputOption::VALUE_NONE, 'Only list incomplete activities'); Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); PropertyFormatter::configureInput($this->getDefinition()); - $this->addProjectOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); $this->addOption('environment', 'e', InputOption::VALUE_REQUIRED, '[Deprecated option, not used]'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->warnAboutDeprecatedOptions(['environment']); - $this->validateInput($input, true); + $this->io->warnAboutDeprecatedOptions(['environment']); + $selection = $this->selector->getSelection($input, new SelectorConfig(envRequired: false)); - $project = $this->getSelectedProject(); + $project = $selection->getProject(); $integration = $this->selectIntegration($project, $input->getArgument('id'), $input->isInteractive()); if (!$integration) { return 1; } - - /** @var \Platformsh\Cli\Service\ActivityLoader $loader */ - $loader = $this->getService('activity_loader'); - $activities = $loader->loadFromInput($integration, $input); + $activities = $this->activityLoader->loadFromInput($integration, $input); if ($activities === []) { $this->stdErr->writeln('No activities found'); return 1; } - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - $timingTypes = ['execute', 'wait', 'build', 'deploy']; $rows = []; foreach ($activities as $activity) { $row = [ 'id' => new AdaptiveTableCell($activity->id, ['wrap' => false]), - 'created' => $formatter->format($activity['created_at'], 'created_at'), - 'completed' => $formatter->format($activity['completed_at'], 'completed_at'), - 'description' => ActivityMonitor::getFormattedDescription($activity, !$table->formatIsMachineReadable()), + 'created' => $this->propertyFormatter->format($activity['created_at'], 'created_at'), + 'completed' => $this->propertyFormatter->format($activity['completed_at'], 'completed_at'), + 'description' => ActivityMonitor::getFormattedDescription($activity, !$this->table->formatIsMachineReadable()), 'type' => new AdaptiveTableCell($activity->type, ['wrap' => false]), 'progress' => $activity->getCompletionPercent() . '%', 'state' => ActivityMonitor::formatState($activity->state), - 'result' => ActivityMonitor::formatResult($activity->result, !$table->formatIsMachineReadable()), + 'result' => ActivityMonitor::formatResult($activity->result, !$this->table->formatIsMachineReadable()), ]; $timings = $activity->getProperty('timings', false, false) ?: []; foreach ($timingTypes as $timingType) { @@ -109,19 +118,19 @@ protected function execute(InputInterface $input, OutputInterface $output) $rows[] = $row; } - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->stdErr->writeln(sprintf( 'Activities on the project %s, integration %s (%s):', - $this->api()->getProjectLabel($project), + $this->api->getProjectLabel($project), $integration->id, - $integration->type + $integration->type, )); } - $table->render($rows, $this->tableHeader, $this->defaultColumns); + $this->table->render($rows, $this->tableHeader, $this->defaultColumns); - if (!$table->formatIsMachineReadable()) { - $executable = $this->config()->get('application.executable'); + if (!$this->table->formatIsMachineReadable()) { + $executable = $this->config->getStr('application.executable'); $max = $input->getOption('limit') ? (int) $input->getOption('limit') : 10; $maybeMoreAvailable = count($activities) === $max; @@ -132,18 +141,18 @@ protected function execute(InputInterface $input, OutputInterface $output) . ' To display older activities, increase --limit above %d, or set --start to a date in the past.' . ' For more information, run: %s integration:activity:list -h', $max, - $executable + $executable, )); } $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf( 'To view the log for an activity, run: %s integration:activity:log [integration] [activity]', - $executable + $executable, )); $this->stdErr->writeln(sprintf( 'To view more information about an activity, run: %s integration:activity:get [integration] [activity]', - $executable + $executable, )); } diff --git a/src/Command/Integration/Activity/IntegrationActivityLogCommand.php b/src/Command/Integration/Activity/IntegrationActivityLogCommand.php index eeb1ac9ea4..3afaaad5d8 100644 --- a/src/Command/Integration/Activity/IntegrationActivityLogCommand.php +++ b/src/Command/Integration/Activity/IntegrationActivityLogCommand.php @@ -1,39 +1,50 @@ setName('integration:activity:log') ->addArgument('integration', InputArgument::OPTIONAL, 'An integration ID. Leave blank to choose from a list.') ->addArgument('activity', InputArgument::OPTIONAL, 'The activity ID. Defaults to the most recent integration activity.') - ->addOption('timestamps', 't', InputOption::VALUE_NONE, 'Display a timestamp next to each message') - ->setDescription('Display the log for an integration activity'); + ->addOption('timestamps', 't', InputOption::VALUE_NONE, 'Display a timestamp next to each message'); PropertyFormatter::configureInput($this->getDefinition()); - $this->addProjectOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); $this->addOption('environment', 'e', InputOption::VALUE_REQUIRED, '[Deprecated option, not used]'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->warnAboutDeprecatedOptions(['environment']); - $this->validateInput($input, true); + $this->io->warnAboutDeprecatedOptions(['environment']); + $selection = $this->selector->getSelection($input, new SelectorConfig(envRequired: false)); - $project = $this->getSelectedProject(); + $project = $selection->getProject(); $integration = $this->selectIntegration($project, $input->getArgument('integration'), $input->isInteractive()); if (!$integration) { @@ -44,12 +55,8 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($id) { $activity = $project->getActivity($id); if (!$activity) { - $activity = $this->api()->matchPartialId($id, $integration->getActivities(), 'Activity'); - if (!$activity) { - $this->stdErr->writeln("Integration activity not found: $id"); - - return 1; - } + /** @var Activity $activity */ + $activity = $this->api->matchPartialId($id, $integration->getActivities(), 'Activity'); } } else { $activities = $integration->getActivities(); @@ -61,15 +68,12 @@ protected function execute(InputInterface $input, OutputInterface $output) } } - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - $this->stdErr->writeln([ sprintf('Integration ID: %s', $integration->id), sprintf('Activity ID: %s', $activity->id), sprintf('Type: %s', $activity->type), sprintf('Description: %s', ActivityMonitor::getFormattedDescription($activity)), - sprintf('Created: %s', $formatter->format($activity->created_at, 'created_at')), + sprintf('Created: %s', $this->propertyFormatter->format($activity->created_at, 'created_at')), sprintf('State: %s', ActivityMonitor::formatState($activity->state)), 'Log: ', ]); @@ -78,21 +82,18 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($timestamps && $input->hasOption('date-fmt') && $input->getOption('date-fmt') !== null) { $timestamps = $input->getOption('date-fmt'); } elseif ($timestamps) { - $timestamps = $this->config()->getWithDefault('application.date_format', 'c'); + $timestamps = $this->config->getStr('application.date_format'); } - - /** @var ActivityMonitor $monitor */ - $monitor = $this->getService('activity_monitor'); if (!$this->runningViaMulti && !$activity->isComplete() && $activity->state !== Activity::STATE_CANCELLED) { - $monitor->waitAndLog($activity, 3, $timestamps, false, $output); + $this->activityMonitor->waitAndLog($activity, 3, $timestamps, false, $output); // Once the activity is complete, something has probably changed in // the project's environments, so this is a good opportunity to // clear the cache. - $this->api()->clearEnvironmentsCache($activity->project); + $this->api->clearEnvironmentsCache($activity->project); } else { $items = $activity->readLog(); - $output->write($monitor->formatLog($items, $timestamps)); + $output->write($this->activityMonitor->formatLog($items, $timestamps)); } return 0; diff --git a/src/Command/Integration/IntegrationAddCommand.php b/src/Command/Integration/IntegrationAddCommand.php index 3aadce82e5..5adc11d0a7 100644 --- a/src/Command/Integration/IntegrationAddCommand.php +++ b/src/Command/Integration/IntegrationAddCommand.php @@ -1,44 +1,51 @@ setName('integration:add') - ->setDescription('Add an integration to the project'); $this->getForm()->configureInputDefinition($this->getDefinition()); - $this->addProjectOption()->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); $this->addExample( 'Add an integration with a GitHub repository', - '--type github --repository myuser/example-repo --token 9218376e14c2797e0d06e8d2f918d45f --fetch-branches 0' + '--type github --repository myuser/example-repo --token 9218376e14c2797e0d06e8d2f918d45f --fetch-branches 0', ); $this->addExample( 'Add an integration with a GitLab repository', - '--type gitlab --server-project mygroup/example-repo --token 22fe4d70dfbc20e4f668568a0b5422e2 --base-url https://gitlab.example.com' + '--type gitlab --server-project mygroup/example-repo --token 22fe4d70dfbc20e4f668568a0b5422e2 --base-url https://gitlab.example.com', ); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); - $project = $this->getSelectedProject(); - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); + $selection = $this->selector->getSelection($input); + $this->selection = $selection; + $project = $selection->getProject(); try { $values = $this->getForm() - ->resolveOptions($input, $this->stdErr, $questionHelper); + ->resolveOptions($input, $this->stdErr, $this->questionHelper); } catch (ConditionalFieldException $e) { return $this->handleConditionalFieldException($e); } @@ -66,9 +73,9 @@ protected function execute(InputInterface $input, OutputInterface $output) if (isset($values['type']) && in_array($values['type'], ['github', 'gitlab', 'bitbucket', 'bitbucket_server'])) { $this->stdErr->writeln( "Warning: adding a '" . $values['type'] . "' integration will automatically synchronize code from the external Git repository." - . "\nThis means it can overwrite all the code in your project.\n" + . "\nThis means it can overwrite all the code in your project.\n", ); - if (!$questionHelper->confirm('Are you sure you want to continue?')) { + if (!$this->questionHelper->confirm('Are you sure you want to continue?')) { return 1; } } @@ -96,13 +103,12 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln("Created integration $integration->id (type: {$values['type']})"); $success = true; - if ($this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); + if ($this->activityMonitor->shouldWait($input)) { + $activityMonitor = $this->activityMonitor; $success = $activityMonitor->waitMultiple($result->getActivities(), $project); } - $this->updateGitUrl($oldGitUrl); + $this->updateGitUrl($oldGitUrl, $project); $this->displayIntegration($integration); diff --git a/src/Command/Integration/IntegrationCommandBase.php b/src/Command/Integration/IntegrationCommandBase.php index 4db35decc3..92f137ad42 100644 --- a/src/Command/Integration/IntegrationCommandBase.php +++ b/src/Command/Integration/IntegrationCommandBase.php @@ -1,7 +1,19 @@ */ + private array $bitbucketAccessTokens = []; + + protected ?Selection $selection = null; + + #[Required] + public function autowire(Api $api, LocalProject $localProject, PropertyFormatter $propertyFormatter, QuestionHelper $questionHelper, Selector $selector, Table $table): void + { + $this->api = $api; + $this->localProject = $localProject; + $this->propertyFormatter = $propertyFormatter; + $this->questionHelper = $questionHelper; + $this->table = $table; + $this->selector = $selector; + } + + protected function selectIntegration(Project $project, ?string $id, bool $interactive): Integration|false + { if (!$id && !$interactive) { $this->stdErr->writeln('An integration ID is required.'); @@ -43,19 +68,18 @@ protected function selectIntegration(Project $project, $id, $interactive) { return false; } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); $choices = []; foreach ($integrations as $integration) { $choices[$integration->id] = sprintf('%s (%s)', $integration->id, $integration->type); } - $id = $questionHelper->choose($choices, 'Enter a number to choose an integration:'); + $id = $this->questionHelper->choose($choices, 'Enter a number to choose an integration:'); } $integration = $project->getIntegration($id); if (!$integration) { try { - $integration = $this->api()->matchPartialId($id, $project->getIntegrations(), 'Integration'); + /** @var Integration $integration */ + $integration = $this->api->matchPartialId($id, $project->getIntegrations(), 'Integration'); } catch (\InvalidArgumentException $e) { $this->stdErr->writeln($e->getMessage()); return false; @@ -65,10 +89,7 @@ protected function selectIntegration(Project $project, $id, $interactive) { return $integration; } - /** - * @return Form - */ - protected function getForm() + protected function getForm(): Form { if (!isset($this->form)) { $this->form = Form::fromArray($this->getFields()); @@ -82,7 +103,7 @@ protected function getForm() * * @return int */ - protected function handleConditionalFieldException(ConditionalFieldException $e) + protected function handleConditionalFieldException(ConditionalFieldException $e): int { $previousValues = $e->getPreviousValues(); $field = $e->getField(); @@ -91,7 +112,7 @@ protected function handleConditionalFieldException(ConditionalFieldException $e) $this->stdErr->writeln(\sprintf( 'The option --%s cannot be used with the integration type %s.', $field->getOptionName(), - $previousValues['type'] + $previousValues['type'], )); return 1; } @@ -101,17 +122,15 @@ protected function handleConditionalFieldException(ConditionalFieldException $e) /** * Performs extra logic on values after the form is complete. * - * @param array $values + * @param array $values * @param Integration|null $integration * - * @return array + * @return array */ - protected function postProcessValues(array $values, Integration $integration = null) + protected function postProcessValues(array $values, ?Integration $integration = null): array { // Find the integration type. - $type = isset($values['type']) - ? $values['type'] - : ($integration !== null ? $integration->type : null); + $type = $values['type'] ?? $integration?->type; // Process Bitbucket Server values. if ($type === 'bitbucket_server') { @@ -121,8 +140,8 @@ protected function postProcessValues(array $values, Integration $integration = n unset($values['bitbucket_url']); } // Split bitbucket_server "repository" into project/repository. - if (isset($values['repository']) && strpos($values['repository'], '/', 1) !== false) { - list($values['project'], $values['repository']) = explode('/', $values['repository'], 2); + if (isset($values['repository']) && str_contains(substr((string) $values['repository'], 1), '/')) { + [$values['project'], $values['repository']] = explode('/', (string) $values['repository'], 2); } } @@ -137,7 +156,7 @@ protected function postProcessValues(array $values, Integration $integration = n if (isset($values['headers'])) { $map = []; foreach ($values['headers'] as $header) { - $parts = explode(':', $header, 2); + $parts = explode(':', (string) $header, 2); $map[$parts[0]] = isset($parts[1]) ? ltrim($parts[1]) : ''; } $values['headers'] = $map; @@ -155,12 +174,12 @@ protected function postProcessValues(array $values, Integration $integration = n /** * Returns a list of integration capability information on the selected project, if any. * - * @return array + * @return array{enabled: bool, config?: array} */ - private function selectedProjectIntegrations() + private function selectedProjectIntegrations(): array { static $cache = []; - $project = $this->getSelectedProject(); + $project = $this->selection->getProject(); if (!isset($cache[$project->id])) { $cache[$project->id] = $project->hasLink('#capabilities') ? $project->getCapabilities()->integrations : []; } @@ -170,7 +189,7 @@ private function selectedProjectIntegrations() /** * @return Field[] */ - private function getFields() + private function getFields(): array { $allSupportedTypes = [ 'bitbucket', @@ -196,13 +215,13 @@ private function getFields() 'description' => 'The integration type', 'questionLine' => '', 'options' => $allSupportedTypes, - 'validator' => function ($value) use ($allSupportedTypes) { + 'validator' => function ($value) use ($allSupportedTypes): ?string { // If the type isn't supported at all, fall back to the default validator. if (!in_array($value, $allSupportedTypes, true)) { return null; } // If the type is supported, check if it is available on the project. - if ($this->hasSelectedProject()) { + if ($this->selection->hasProject()) { $integrations = $this->selectedProjectIntegrations(); if (!empty($integrations['enabled']) && empty($integrations['config'][$value]['enabled'])) { return "The integration type '$value' is not available on this project."; @@ -210,13 +229,11 @@ private function getFields() } return null; }, - 'optionsCallback' => function () use ($allSupportedTypes) { - if ($this->hasSelectedProject()) { + 'optionsCallback' => function () use ($allSupportedTypes): array { + if ($this->selection->hasProject()) { $integrations = $this->selectedProjectIntegrations(); if (!empty($integrations['enabled']) && !empty($integrations['config'])) { - return array_filter($allSupportedTypes, function ($type) use ($integrations) { - return !empty($integrations['config'][$type]['enabled']); - }); + return array_filter($allSupportedTypes, fn($type): bool => !empty($integrations['config'][$type]['enabled'])); } } return $allSupportedTypes; @@ -277,9 +294,7 @@ private function getFields() 'gitlab', ]], 'description' => 'The project (e.g. \'namespace/repo\')', - 'validator' => function ($string) { - return strpos($string, '/', 1) !== false; - }, + 'validator' => fn($string): bool => str_contains(substr((string) $string, 1), '/'), ]), 'repository' => new Field('Repository', [ 'conditions' => ['type' => [ @@ -289,9 +304,7 @@ private function getFields() ]], 'description' => 'The repository to track (e.g. \'owner/repository\')', 'questionLine' => 'The repository (e.g. \'owner/repository\')', - 'validator' => function ($string) { - return substr_count($string, '/', 1) === 1; - }, + 'validator' => fn($string): bool => substr_count((string) $string, '/', 1) === 1, 'normalizer' => function ($string) { if (preg_match('#^https?://#', $string)) { return parse_url($string, PHP_URL_PATH); @@ -324,14 +337,14 @@ private function getFields() ], ]), 'build_pull_requests_post_merge' => new BooleanField('Build pull requests post-merge', [ - 'conditions' => [ - 'type' => [ - 'github', + 'conditions' => [ + 'type' => [ + 'github', + ], + 'build_pull_requests' => true, ], - 'build_pull_requests' => true, - ], - 'default' => false, - 'description' => 'Build pull requests based on their post-merge state', + 'default' => false, + 'description' => 'Build pull requests based on their post-merge state', ]), 'build_wip_merge_requests' => new BooleanField('Build WIP merge requests', [ 'conditions' => [ @@ -442,7 +455,7 @@ private function getFields() 'contentsAsValue' => true, 'description' => 'The name of a local file that contains the script to upload', 'normalizer' => function ($value) { - if (getenv('HOME') && strpos($value, '~/') === 0) { + if (getenv('HOME') && str_starts_with($value, '~/')) { return getenv('HOME') . '/' . substr($value, 2); } @@ -496,8 +509,8 @@ private function getFields() 'health.email', ]], 'description' => 'The recipient email address(es)', - 'validator' => function ($emails) { - $invalid = array_filter($emails, function ($email) { + 'validator' => function ($emails): string|true { + $invalid = array_filter($emails, function ($email): bool { // The special placeholders #viewers and #admins are // valid recipients. if (in_array($email, ['#viewers', '#admins'])) { @@ -529,7 +542,7 @@ private function getFields() 'conditions' => ['type' => 'sumologic'], 'description' => 'The Sumo Logic category, used for filtering', 'required' => false, - 'normalizer' => function ($val) { return (string) $val; }, + 'normalizer' => fn($val): string => (string) $val, ]), 'index' => new Field('Index', [ 'conditions' => ['type' => 'splunk'], @@ -560,7 +573,7 @@ private function getFields() 'conditions' => ['type' => ['syslog']], 'description' => 'Syslog relay/collector port', 'autoCompleterValues' => ['6514'], - 'validator' => function ($value) { return is_numeric($value) && $value >= 0 && $value <= 65535 ? true : "Invalid port number: $value"; }, + 'validator' => fn($value) => is_numeric($value) && $value >= 0 && $value <= 65535 ? true : "Invalid port number: $value", ]), 'facility' => new Field('Facility', [ 'conditions' => ['type' => ['syslog']], @@ -568,7 +581,7 @@ private function getFields() 'default' => 1, 'required' => false, 'avoidQuestion' => true, - 'validator' => function ($value) { return is_numeric($value) && $value >= 0 && $value <= 23 ? true : "Invalid syslog facility code: $value"; }, + 'validator' => fn($value) => is_numeric($value) && $value >= 0 && $value <= 23 ? true : "Invalid syslog facility code: $value", ]), 'message_format' => new OptionsField('Message format', [ 'conditions' => ['type' => ['syslog']], @@ -616,7 +629,7 @@ private function getFields() // QuestionHelper reads only one input line, it is not // practical to ask for headers interactively. 'avoidQuestion' => false, - 'validator' => function ($headers) { + 'validator' => function ($headers): string|true { $uniqueNames = []; foreach ($headers as $header) { $parts = \explode(':', $header, 2); @@ -628,46 +641,36 @@ private function getFields() } $uniqueNames[$parts[0]] = true; } - return TRUE; + return true; }, ]), ]; } - /** - * @param Integration $integration - */ - protected function displayIntegration(Integration $integration) + protected function displayIntegration(Integration $integration): void { - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - $info = []; foreach ($integration->getProperties() as $property => $value) { - $info[$property] = $formatter->format($value, $property); + $info[$property] = $this->propertyFormatter->format($value, $property); } if ($integration->hasLink('#hook')) { - $info['hook_url'] = $formatter->format($integration->getLink('#hook')); + $info['hook_url'] = $this->propertyFormatter->format($integration->getLink('#hook')); } - $table->renderSimple(array_values($info), array_keys($info)); + $this->table->renderSimple(array_values($info), array_keys($info)); } /** - * Obtain an OAuth2 token for Bitbucket from the given app credentials. + * Obtains an OAuth2 token for Bitbucket from the given app credentials. * - * @param array $credentials - * - * @return string + * @param array{key: string, secret: string} $credentials */ - protected function getBitbucketAccessToken(array $credentials) + protected function getBitbucketAccessToken(array $credentials): string { if (isset($this->bitbucketAccessTokens[$credentials['key']])) { return $this->bitbucketAccessTokens[$credentials['key']]; } - $result = $this->api() + $response = $this->api ->getExternalHttpClient() ->post('https://bitbucket.org/site/oauth2/access_token', [ 'auth' => [$credentials['key'], $credentials['secret']], @@ -676,7 +679,7 @@ protected function getBitbucketAccessToken(array $credentials) ], ]); - $data = $result->json(); + $data = (array) Utils::jsonDecode((string) $response->getBody(), true); if (!isset($data['access_token'])) { throw new \RuntimeException('Access token not found in Bitbucket response'); } @@ -687,19 +690,17 @@ protected function getBitbucketAccessToken(array $credentials) } /** - * Validate Bitbucket credentials. - * - * @param array $credentials + * Validates Bitbucket credentials. * - * @return string|TRUE + * @param array{key: string, secret: string} $credentials */ - protected function validateBitbucketCredentials(array $credentials) + protected function validateBitbucketCredentials(array $credentials): true|string { try { $this->getBitbucketAccessToken($credentials); } catch (\Exception $e) { $message = 'Invalid Bitbucket credentials'; - if ($e instanceof BadResponseException && $e->getResponse() && $e->getResponse()->getStatusCode() === 400) { + if ($e instanceof BadResponseException && $e->getResponse()->getStatusCode() === 400) { $message .= "\n" . 'Ensure that the OAuth consumer key and secret are valid.' . "\n" . 'Additionally, ensure that the OAuth consumer has a callback URL set (even just to http://localhost).'; } @@ -707,23 +708,22 @@ protected function validateBitbucketCredentials(array $credentials) return $message; } - return TRUE; + return true; } /** * Lists validation errors found in an integration. * - * @param array $errors - * @param \Symfony\Component\Console\Output\OutputInterface $output + * @param array $errors */ - protected function listValidationErrors(array $errors, OutputInterface $output) + protected function listValidationErrors(array $errors, OutputInterface $output): void { if (count($errors) === 1) { $this->stdErr->writeln('The following error was found:'); } else { $this->stdErr->writeln(sprintf( 'The following %d errors were found:', - count($errors) + count($errors), )); } @@ -738,17 +738,14 @@ protected function listValidationErrors(array $errors, OutputInterface $output) /** * Updates the Git remote URL for the current project. - * - * @param string $oldGitUrl */ - protected function updateGitUrl($oldGitUrl) + protected function updateGitUrl(string $oldGitUrl, Project $project): void { - if (!$this->selectedProjectIsCurrent()) { + if (!$this->selector->isProjectCurrent($project)) { return; } - $project = $this->getCurrentProject(); - $projectRoot = $this->getProjectRoot(); - if (!$project || !$projectRoot) { + $projectRoot = $this->selector->getProjectRoot(); + if (!$projectRoot) { return; } $project->refresh(); @@ -757,8 +754,6 @@ protected function updateGitUrl($oldGitUrl) return; } $this->stdErr->writeln(sprintf('Updating Git remote URL from %s to %s', $oldGitUrl, $newGitUrl)); - /** @var \Platformsh\Cli\Local\LocalProject $localProject */ - $localProject = $this->getService('local.project'); - $localProject->ensureGitRemote($projectRoot, $newGitUrl); + $this->localProject->ensureGitRemote($projectRoot, $newGitUrl); } } diff --git a/src/Command/Integration/IntegrationDeleteCommand.php b/src/Command/Integration/IntegrationDeleteCommand.php index f8cd179ade..7194bbfde8 100644 --- a/src/Command/Integration/IntegrationDeleteCommand.php +++ b/src/Command/Integration/IntegrationDeleteCommand.php @@ -1,28 +1,38 @@ setName('integration:delete') - ->addArgument('id', InputArgument::OPTIONAL, 'The integration ID. Leave blank to choose from a list.') - ->setDescription('Delete an integration from a project'); - $this->addProjectOption()->addWaitOptions(); + ->addArgument('id', InputArgument::OPTIONAL, 'The integration ID. Leave blank to choose from a list.'); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); - $project = $this->getSelectedProject(); + $selection = $this->selector->getSelection($input); + $project = $selection->getProject(); $integration = $this->selectIntegration($project, $input->getArgument('id'), $input->isInteractive()); if (!$integration) { @@ -30,9 +40,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } $confirmText = sprintf('Delete the integration %s (type: %s)?', $integration->id, $integration->type); - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - if (!$questionHelper->confirm($confirmText)) { + if (!$this->questionHelper->confirm($confirmText)) { return 1; } @@ -42,13 +50,12 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(sprintf('Deleted integration %s', $integration->id)); - if ($this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); - $activityMonitor->waitMultiple($result->getActivities(), $this->getSelectedProject()); + if ($this->activityMonitor->shouldWait($input)) { + $activityMonitor = $this->activityMonitor; + $activityMonitor->waitMultiple($result->getActivities(), $selection->getProject()); } - $this->updateGitUrl($oldGitUrl); + $this->updateGitUrl($oldGitUrl, $project); return 0; } diff --git a/src/Command/Integration/IntegrationGetCommand.php b/src/Command/Integration/IntegrationGetCommand.php index 1007c15253..df6bc1d7cc 100644 --- a/src/Command/Integration/IntegrationGetCommand.php +++ b/src/Command/Integration/IntegrationGetCommand.php @@ -1,33 +1,42 @@ setName('integration:get') ->addArgument('id', InputArgument::OPTIONAL, 'An integration ID. Leave blank to choose from a list.') - ->addOption('property', 'P', InputOption::VALUE_OPTIONAL, 'The integration property to view') - ->setDescription('View details of an integration'); + ->addOption('property', 'P', InputOption::VALUE_OPTIONAL, 'The integration property to view'); Table::configureInput($this->getDefinition()); - $this->addProjectOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); + $selection = $this->selector->getSelection($input); - $project = $this->getSelectedProject(); + $project = $selection->getProject(); $integration = $this->selectIntegration($project, $input->getArgument('id'), $input->isInteractive()); if (!$integration) { @@ -38,11 +47,10 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($property === 'hook_url' && $integration->hasLink('#hook')) { $value = $integration->getLink('#hook'); } else { - $value = $this->api()->getNestedProperty($integration, $property); + $value = $this->api->getNestedProperty($integration, $property); } - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); + $formatter = $this->propertyFormatter; $output->writeln($formatter->format($value, $property)); return 0; diff --git a/src/Command/Integration/IntegrationListCommand.php b/src/Command/Integration/IntegrationListCommand.php index c1e86ca80d..1625dfa3fc 100644 --- a/src/Command/Integration/IntegrationListCommand.php +++ b/src/Command/Integration/IntegrationListCommand.php @@ -1,36 +1,43 @@ */ + private array $tableHeader = ['ID', 'Type', 'Summary']; + public function __construct(private readonly Config $config, private readonly Selector $selector, private readonly Table $table) + { + parent::__construct(); + } - /** - * {@inheritdoc} - */ - protected function configure() + protected function configure(): void { $this - ->setName('integration:list') - ->setAliases(['integrations']) - ->setDescription('View a list of project integration(s)') ->addOption('type', 't', InputOption::VALUE_REQUIRED, 'Filter by type'); Table::configureInput($this->getDefinition(), $this->tableHeader); - $this->addProjectOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); + $selection = $this->selector->getSelection($input); - $integrations = $this->getSelectedProject() + $integrations = $selection->getProject() ->getIntegrations(); if (!$integrations) { $this->stdErr->writeln('No integrations found'); @@ -39,13 +46,8 @@ protected function execute(InputInterface $input, OutputInterface $output) } if ($type = $input->getOption('type')) { - $integrations = array_filter($integrations, function (Integration $i) use ($type) { - return $i->type === $type; - }); + $integrations = array_filter($integrations, fn(Integration $i): bool => $i->type === $type); } - - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); $rows = []; foreach ($integrations as $integration) { @@ -56,10 +58,10 @@ protected function execute(InputInterface $input, OutputInterface $output) ]; } - $table->render($rows, $this->tableHeader); + $this->table->render($rows, $this->tableHeader); - if (!$table->formatIsMachineReadable()) { - $executable = $this->config()->get('application.executable'); + if (!$this->table->formatIsMachineReadable()) { + $executable = $this->config->getStr('application.executable'); $this->stdErr->writeln(''); $this->stdErr->writeln('View integration details with: ' . $executable . ' integration:get [id]'); $this->stdErr->writeln(''); @@ -75,7 +77,7 @@ protected function execute(InputInterface $input, OutputInterface $output) * * @return string */ - protected function getIntegrationSummary(Integration $integration) + protected function getIntegrationSummary(Integration $integration): string { $details = $integration->getProperties(); unset($details['id'], $details['type']); @@ -130,7 +132,7 @@ protected function getIntegrationSummary(Integration $integration) break; default: - $summary = json_encode($details); + $summary = json_encode($details, JSON_THROW_ON_ERROR); } if (strlen($summary) > 240) { diff --git a/src/Command/Integration/IntegrationUpdateCommand.php b/src/Command/Integration/IntegrationUpdateCommand.php index 93b35fb233..9b85ce2546 100644 --- a/src/Command/Integration/IntegrationUpdateCommand.php +++ b/src/Command/Integration/IntegrationUpdateCommand.php @@ -1,41 +1,52 @@ setName('integration:update') - ->addArgument('id', InputArgument::OPTIONAL, 'The ID of the integration to update') - ->setDescription('Update an integration'); + ->addArgument('id', InputArgument::OPTIONAL, 'The ID of the integration to update'); $this->getForm()->configureInputDefinition($this->getDefinition()); - $this->addProjectOption()->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); $this->addExample( 'Switch on the "fetch branches" option for a specific integration', - 'ZXhhbXBsZSB --fetch-branches 1' + 'ZXhhbXBsZSB --fetch-branches 1', ); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->warnAboutDeprecatedOptions( + $this->io->warnAboutDeprecatedOptions( ['type'], - 'The --type option is not supported on the integration:update command. The integration type cannot be changed.' + 'The --type option is not supported on the integration:update command. The integration type cannot be changed.', ); - $this->validateInput($input); + $selection = $this->selector->getSelection($input); + $this->selection = $selection; - $project = $this->getSelectedProject(); + $project = $selection->getProject(); $integration = $this->selectIntegration($project, $input->getArgument('id'), $input->isInteractive()); if (!$integration) { @@ -95,7 +106,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(sprintf( 'The integration %s (type: %s) is invalid.', $integration->id, - $integration->type + $integration->type, )); $this->stdErr->writeln(''); $this->listValidationErrors($errors, $output); @@ -111,10 +122,9 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->displayIntegration($integration); - if ($this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); - $activityMonitor->waitMultiple($result->getActivities(), $this->getSelectedProject()); + if ($this->activityMonitor->shouldWait($input)) { + $activityMonitor = $this->activityMonitor; + $activityMonitor->waitMultiple($result->getActivities(), $selection->getProject()); } return 0; @@ -123,13 +133,11 @@ protected function execute(InputInterface $input, OutputInterface $output) /** * Compare new and old integration values. * - * @param mixed $a - * @param mixed $b * * @return bool * True if the values are considered the same, false otherwise. */ - private function valuesAreEqual($a, $b) + private function valuesAreEqual(mixed $a, mixed $b): bool { if (is_array($a) && is_array($b)) { ksort($a); diff --git a/src/Command/Integration/IntegrationValidateCommand.php b/src/Command/Integration/IntegrationValidateCommand.php index 3c3ee7a37a..d271ac28a8 100644 --- a/src/Command/Integration/IntegrationValidateCommand.php +++ b/src/Command/Integration/IntegrationValidateCommand.php @@ -1,42 +1,50 @@ setName('integration:validate') - ->addArgument('id', InputArgument::OPTIONAL, 'An integration ID. Leave blank to choose from a list.') - ->setDescription('Validate an existing integration'); - $this->addProjectOption(); - $this->setHelp(<<addArgument('id', InputArgument::OPTIONAL, 'An integration ID. Leave blank to choose from a list.'); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->setHelp( + <<validateInput($input); + $selection = $this->selector->getSelection($input); - $project = $this->getSelectedProject(); + $project = $selection->getProject(); $integration = $this->selectIntegration($project, $input->getArgument('id'), $input->isInteractive()); if (!$integration) { @@ -46,12 +54,12 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(sprintf( 'Validating the integration %s (type: %s)...', $integration->id, - $integration->type + $integration->type, )); try { $errors = $integration->validate(); - } catch (OperationUnavailableException $e) { + } catch (OperationUnavailableException) { $this->stdErr->writeln('This integration does not support validation.'); return 1; diff --git a/src/Command/LegacyMigrateCommand.php b/src/Command/LegacyMigrateCommand.php index 25da02b2dd..98f24e215d 100644 --- a/src/Command/LegacyMigrateCommand.php +++ b/src/Command/LegacyMigrateCommand.php @@ -1,60 +1,66 @@ setName('legacy-migrate') - ->setDescription('Migrate from the legacy file structure') ->addOption('no-backup', null, InputOption::VALUE_NONE, 'Do not create a backup of the project.'); - $cliName = $this->config()->get('application.name'); - $localDir = $this->config()->get('local.local_dir'); - $this->setHelp(<<config->getStr('application.name'); + $localDir = $this->config->getStr('local.local_dir'); + $this->setHelp( + <<getService('local.project'); - return !$localProject->getLegacyProjectRoot(); + return !$this->localProject->getLegacyProjectRoot(); } - public function isEnabled() + public function isEnabled(): bool { - return $this->config()->has('local.project_config_legacy') && parent::isEnabled(); + return $this->config->has('local.project_config_legacy') && parent::isEnabled(); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - /** @var \Platformsh\Cli\Local\LocalProject $localProject */ - $localProject = $this->getService('local.project'); - $legacyRoot = $localProject->getLegacyProjectRoot(); + $legacyRoot = $this->localProject->getLegacyProjectRoot(); if (!$legacyRoot) { - if ($this->getProjectRoot()) { + if ($this->selector->getProjectRoot()) { $this->stdErr->writeln(sprintf( 'This project is already compatible with the %s version 3.x.', - $this->config()->get('application.name') + $this->config->getStr('application.name'), )); return 0; @@ -62,11 +68,6 @@ protected function execute(InputInterface $input, OutputInterface $output) throw new RootNotFoundException(); } - $cwd = getcwd(); - - /** @var \Platformsh\Cli\Service\Filesystem $fs */ - $fs = $this->getService('fs'); - $repositoryDir = $legacyRoot . '/repository'; if (!is_dir($repositoryDir)) { $this->stdErr->writeln('Directory not found: ' . $repositoryDir . ''); @@ -84,70 +85,70 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln('Backup destination already exists: ' . $backup . ''); $this->stdErr->writeln( 'Move (or delete) the backup, then run ' - . $this->config()->get('application.executable') - . ' legacy-migrate to continue.' + . $this->config->getStr('application.executable') + . ' legacy-migrate to continue.', ); return 1; } $this->stdErr->writeln('Backing up entire project to: ' . $backup); - $fs->archiveDir($legacyRoot, $backup); + $this->filesystem->archiveDir($legacyRoot, $backup); } - $this->stdErr->writeln('Creating directory: ' . $this->config()->get('local.local_dir')); - $localProject->ensureLocalDir($repositoryDir); + $this->stdErr->writeln('Creating directory: ' . $this->config->getStr('local.local_dir')); + $this->localProject->ensureLocalDir($repositoryDir); if (file_exists($legacyRoot . '/shared')) { $this->stdErr->writeln('Moving "shared" directory.'); - if (is_dir($repositoryDir . '/' . $this->config()->get('local.shared_dir'))) { - $fs->copyAll($legacyRoot . '/shared', $repositoryDir . '/' . $this->config()->get('local.shared_dir')); - $fs->remove($legacyRoot . '/shared'); + if (is_dir($repositoryDir . '/' . $this->config->getStr('local.shared_dir'))) { + $this->filesystem->copyAll($legacyRoot . '/shared', $repositoryDir . '/' . $this->config->getStr('local.shared_dir')); + $this->filesystem->remove($legacyRoot . '/shared'); } else { - rename($legacyRoot . '/shared', $repositoryDir . '/' . $this->config()->get('local.shared_dir')); + rename($legacyRoot . '/shared', $repositoryDir . '/' . $this->config->getStr('local.shared_dir')); } } - if (file_exists($legacyRoot . '/' . $this->config()->get('local.project_config_legacy'))) { + if (file_exists($legacyRoot . '/' . $this->config->getStr('local.project_config_legacy'))) { $this->stdErr->writeln('Moving project config file.'); - $fs->copy( - $legacyRoot . '/' . $this->config()->get('local.project_config_legacy'), - $legacyRoot . '/' . $this->config()->get('local.project_config') + $this->filesystem->copy( + $legacyRoot . '/' . $this->config->getStr('local.project_config_legacy'), + $legacyRoot . '/' . $this->config->getStr('local.project_config'), ); - $fs->remove($legacyRoot . '/' . $this->config()->get('local.project_config_legacy')); + $this->filesystem->remove($legacyRoot . '/' . $this->config->getStr('local.project_config_legacy')); } if (file_exists($legacyRoot . '/.build-archives')) { $this->stdErr->writeln('Removing old build archives.'); - $fs->remove($legacyRoot . '/.build-archives'); + $this->filesystem->remove($legacyRoot . '/.build-archives'); } if (file_exists($legacyRoot . '/builds')) { $this->stdErr->writeln('Removing old builds.'); - $fs->remove($legacyRoot . '/builds'); + $this->filesystem->remove($legacyRoot . '/builds'); } if (is_link($legacyRoot . '/www')) { $this->stdErr->writeln('Removing old "www" symlink.'); - $fs->remove($legacyRoot . '/www'); + $this->filesystem->remove($legacyRoot . '/www'); $this->stdErr->writeln(''); - $this->stdErr->writeln('After running ' . $this->config()->get('application.executable') . ' build, your web root will be at: ' . $this->config()->getWithDefault('local.web_root', '_www') . ''); + $this->stdErr->writeln('After running ' . $this->config->getStr('application.executable') . ' build, your web root will be at: ' . $this->config->getStr('local.web_root') . ''); $this->stdErr->writeln('You may need to update your local web server configuration.'); $this->stdErr->writeln(''); } $this->stdErr->writeln('Moving repository to be the new project root (this could take some time)...'); - $fs->copyAll($repositoryDir, $legacyRoot, [], true); - $fs->remove($repositoryDir); + $this->filesystem->copyAll($repositoryDir, $legacyRoot, [], true); + $this->filesystem->remove($repositoryDir); if (!is_dir($legacyRoot . '/.git')) { $this->stdErr->writeln('Error: not found: ' . $legacyRoot . '/.git'); return 1; - } elseif (file_exists($legacyRoot . '/' . $this->config()->get('local.project_config_legacy'))) { + } elseif (file_exists($legacyRoot . '/' . $this->config->getStr('local.project_config_legacy'))) { $this->stdErr->writeln(sprintf( 'Error: file still exists: %s', - $legacyRoot . '/' . $this->config()->get('local.project_config_legacy') + $legacyRoot . '/' . $this->config->getStr('local.project_config_legacy'), )); return 1; @@ -155,7 +156,8 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln("\nMigration complete\n"); - if (strpos($cwd, $repositoryDir) === 0) { + $cwd = getcwd(); + if ($cwd !== false && str_starts_with($cwd, $repositoryDir)) { $this->stdErr->writeln('Type this to refresh your shell:'); $this->stdErr->writeln(' cd ' . $legacyRoot . ''); } diff --git a/src/Command/ListCommand.php b/src/Command/ListCommand.php index 8c078f8ea2..2874c69729 100644 --- a/src/Command/ListCommand.php +++ b/src/Command/ListCommand.php @@ -1,4 +1,7 @@ config); + } - protected function configure() + protected function configure(): void { - $this - ->setName('list') + $this->setName('list') + ->setDescription('List commands') ->setDefinition([ new InputArgument('namespace', InputArgument::OPTIONAL, 'The namespace name'), new InputOption('raw', null, InputOption::VALUE_NONE, 'To output raw command list'), new InputOption('format', null, InputOption::VALUE_REQUIRED, 'The output format (txt, xml, json, or md)', 'txt'), ]) - ->setDescription('Lists commands') - ->setHelp(<<<'EOF' -The %command.name% command lists all commands: + ->setHelp( + <<<'EOF' + The %command.name% command lists all commands: - %command.full_name% + %command.full_name% -You can also display the commands for a specific namespace: + You can also display the commands for a specific namespace: - %command.full_name% project + %command.full_name% project -You can also output the information in other formats by using the --format option: + You can also output the information in other formats by using the --format option: - %command.full_name% --format=xml + %command.full_name% --format=xml -It's also possible to get raw list of commands (useful for embedding command runner): + It's also possible to get raw list of commands (useful for embedding command runner): - %command.full_name% --raw -EOF + %command.full_name% --raw + EOF, ) ; $this->addOption('all', null, InputOption::VALUE_NONE, 'Show all commands, including hidden ones'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $helper = new DescriptorHelper(); - $helper->register('txt', new CustomTextDescriptor()); - $helper->register('md', new CustomMarkdownDescriptor()); + $helper->register('txt', new CustomTextDescriptor($this->config->getStr('application.executable'))); + $helper->register('md', new CustomMarkdownDescriptor($this->config->getStr('application.executable'))); $helper->register('json', new CustomJsonDescriptor()); $helper->describe( $output, @@ -64,7 +73,8 @@ protected function execute(InputInterface $input, OutputInterface $output) 'raw_text' => $input->getOption('raw'), 'namespace' => $input->getArgument('namespace'), 'all' => $input->getOption('all'), - ] + ], ); + return 0; } } diff --git a/src/Command/Local/LocalBuildCommand.php b/src/Command/Local/LocalBuildCommand.php index 662e727c01..f52906b137 100644 --- a/src/Command/Local/LocalBuildCommand.php +++ b/src/Command/Local/LocalBuildCommand.php @@ -1,145 +1,151 @@ setName('local:build') - ->setAliases(['build']) ->addArgument('app', InputArgument::IS_ARRAY, 'Specify application(s) to build') - ->setDescription('Build the current project locally') ->addOption( 'abslinks', 'a', InputOption::VALUE_NONE, - 'Use absolute links' + 'Use absolute links', ) ->addOption( 'source', 's', InputOption::VALUE_REQUIRED, - 'The source directory. Defaults to the current project root.' + 'The source directory. Defaults to the current project root.', ) ->addOption( 'destination', 'd', InputOption::VALUE_REQUIRED, - 'The destination, to which the web root of each app will be symlinked. Default: ' . $this->config()->getWithDefault('local.web_root', '_www') + 'The destination, to which the web root of each app will be symlinked. Default: ' . $this->config->getStr('local.web_root'), ) ->addOption( 'copy', 'c', InputOption::VALUE_NONE, - 'Copy to a build directory, instead of symlinking from the source' + 'Copy to a build directory, instead of symlinking from the source', ) ->addOption( 'clone', null, InputOption::VALUE_NONE, - 'Use Git to clone the current HEAD to the build directory' + 'Use Git to clone the current HEAD to the build directory', ) ->addOption( 'run-deploy-hooks', null, InputOption::VALUE_NONE, - 'Run deploy and/or post_deploy hooks' + 'Run deploy and/or post_deploy hooks', ) ->addOption( 'no-clean', null, InputOption::VALUE_NONE, - 'Do not remove old builds' + 'Do not remove old builds', ) ->addOption( 'no-archive', null, InputOption::VALUE_NONE, - 'Do not create or use a build archive' + 'Do not create or use a build archive', ) ->addOption( 'no-backup', null, InputOption::VALUE_NONE, - 'Do not back up the previous build' + 'Do not back up the previous build', ) ->addOption( 'no-cache', null, InputOption::VALUE_NONE, - 'Disable caching' + 'Disable caching', ) ->addOption( 'no-build-hooks', null, InputOption::VALUE_NONE, - 'Do not run post-build hooks' + 'Do not run post-build hooks', ) ->addOption( 'no-deps', null, InputOption::VALUE_NONE, - 'Do not install build dependencies locally' + 'Do not install build dependencies locally', ) ->addOption( 'working-copy', null, InputOption::VALUE_NONE, - 'Drush: use git to clone a repository of each Drupal module rather than simply downloading a version' + 'Drush: use git to clone a repository of each Drupal module rather than simply downloading a version', ) ->addOption( 'concurrency', null, InputOption::VALUE_REQUIRED, 'Drush: set the number of concurrent projects that will be processed at the same time', - 4 + 4, ) ->addOption( 'lock', null, InputOption::VALUE_NONE, - 'Drush: create or update a lock file (only available with Drush version 7+)' + 'Drush: create or update a lock file (only available with Drush version 7+)', ); $this->addExample('Build the current project'); $this->addExample('Build the app "example" without symlinking the source files', 'example --copy'); $this->addExample('Rebuild the current project without using an archive', '--no-archive'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $projectRoot = $this->getProjectRoot(); - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); + $projectRoot = $this->selector->getProjectRoot(); $sourceDirOption = $input->getOption('source'); // If no project root is found, ask the user for a source directory. if (!$projectRoot && !$sourceDirOption && $input->isInteractive()) { - $default = file_exists($this->config()->get('service.project_config_dir')) || is_dir('.git') ? '.' : null; - $sourceDirOption = $questionHelper->askInput('Source directory', $default); + $default = file_exists($this->config->getStr('service.project_config_dir')) || is_dir('.git') ? '.' : null; + $sourceDirOption = $this->questionHelper->askInput('Source directory', $default); } if ($sourceDirOption) { $sourceDir = realpath($sourceDirOption); - if (!is_dir($sourceDir)) { + if ($sourceDir === false || !is_dir($sourceDir)) { throw new InvalidArgumentException('Source directory not found: ' . $sourceDirOption); } // Sensible handling if the user provides a project root as the // source directory. - if (file_exists($sourceDir . $this->config()->get('local.project_config'))) { + if (file_exists($sourceDir . $this->config->getStr('local.project_config'))) { $projectRoot = $sourceDir; } } elseif (!$projectRoot) { @@ -153,25 +159,24 @@ protected function execute(InputInterface $input, OutputInterface $output) // If no project root is found, ask the user for a destination path. if (!$projectRoot && !$destination && $input->isInteractive()) { $default = is_dir($sourceDir . '/.git') && $sourceDir === getcwd() - ? $this->config()->getWithDefault('local.web_root', '_www') + ? $this->config->getStr('local.web_root') : null; - $destination = $questionHelper->askInput('Build destination', $default); + $destination = $this->questionHelper->askInput('Build destination', $default); } if ($destination) { - /** @var \Platformsh\Cli\Service\Filesystem $fs */ - $fs = $this->getService('fs'); + $fs = $this->filesystem; $destination = $fs->makePathAbsolute($destination); } elseif (!$projectRoot) { throw new RootNotFoundException( - 'Project root not found. Specify --destination or go to a project directory.' + 'Project root not found. Specify --destination or go to a project directory.', ); } else { - $destination = $projectRoot . '/' . $this->config()->getWithDefault('local.web_root', '_www'); + $destination = $projectRoot . '/' . $this->config->getStr('local.web_root'); } // Ensure no conflicts between source and destination. - if (strpos($sourceDir, $destination) === 0) { + if (str_starts_with($sourceDir, $destination)) { throw new InvalidArgumentException("The destination '$destination' conflicts with the source '$sourceDir'"); } @@ -183,25 +188,16 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } $default = is_link($destination); - if (!$questionHelper->confirm( + if (!$this->questionHelper->confirm( "The destination exists: $destination. Overwrite?", - $default + $default, )) { return 1; } } - // Map input options to build settings. - $settings = []; - foreach ($input->getOptions() as $name => $value) { - $settings[$name] = $value; - } - $apps = $input->getArgument('app'); - - /** @var \Platformsh\Cli\Local\LocalBuild $builder */ - $builder = $this->getService('local.build'); - $success = $builder->build($settings, $sourceDir, $destination, $apps); + $success = $this->localBuild->build($input->getOptions(), $sourceDir, $destination, $apps); return $success ? 0 : 1; } diff --git a/src/Command/Local/LocalCleanCommand.php b/src/Command/Local/LocalCleanCommand.php index ddec39151a..a46ed9cbe8 100644 --- a/src/Command/Local/LocalCleanCommand.php +++ b/src/Command/Local/LocalCleanCommand.php @@ -1,59 +1,63 @@ setName('local:clean') - ->setAliases(['clean']) - ->setDescription('Remove old project builds') ->addOption( 'keep', null, InputOption::VALUE_REQUIRED, 'The maximum number of builds to keep', - 5 + 5, ) ->addOption( 'max-age', null, InputOption::VALUE_REQUIRED, - 'The maximum age of builds, in seconds. Ignored if not set.' + 'The maximum age of builds, in seconds. Ignored if not set.', ) ->addOption( 'include-active', null, InputOption::VALUE_NONE, - 'Delete active build(s) too' + 'Delete active build(s) too', ); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $projectRoot = $this->getProjectRoot(); + $projectRoot = $this->selector->getProjectRoot(); if (!$projectRoot) { throw new RootNotFoundException(); } - - /** @var \Platformsh\Cli\Local\LocalBuild $builder */ - $builder = $this->getService('local.build'); - $result = $builder->cleanBuilds( + $result = $this->localBuild->cleanBuilds( $projectRoot, $input->getOption('max-age'), $input->getOption('keep'), $input->getOption('include-active'), - false + false, ); if (!$result[0] && !$result[1]) { @@ -67,9 +71,10 @@ protected function execute(InputInterface $input, OutputInterface $output) } } - $archivesResult = $builder->cleanArchives($projectRoot); + $archivesResult = $this->localBuild->cleanArchives($projectRoot); if ($archivesResult[0]) { $this->stdErr->writeln("Deleted {$archivesResult[0]} archive(s)"); } + return 0; } } diff --git a/src/Command/Local/LocalDirCommand.php b/src/Command/Local/LocalDirCommand.php index 151a697261..0a71a7533b 100644 --- a/src/Command/Local/LocalDirCommand.php +++ b/src/Command/Local/LocalDirCommand.php @@ -1,28 +1,34 @@ setName('local:dir') - ->setAliases(['dir']) - ->setDescription('Find the local project root') ->addArgument('subdir', InputArgument::OPTIONAL, "The subdirectory to find ('local', 'web' or 'shared')"); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $projectRoot = $this->getProjectRoot(); + $projectRoot = $this->selector->getProjectRoot(); if (!$projectRoot) { throw new RootNotFoundException(); } @@ -30,11 +36,11 @@ protected function execute(InputInterface $input, OutputInterface $output) $dir = $projectRoot; $subDirs = [ - 'builds' => $this->config()->get('local.build_dir'), - 'local' => $this->config()->get('local.local_dir'), - 'shared' => $this->config()->get('local.shared_dir'), - 'web' => $this->config()->getWithDefault('local.web_root', '_www'), - 'web_root' => $this->config()->getWithDefault('local.web_root', '_www'), + 'builds' => $this->config->getStr('local.build_dir'), + 'local' => $this->config->getStr('local.local_dir'), + 'shared' => $this->config->getStr('local.shared_dir'), + 'web' => $this->config->getStr('local.web_root'), + 'web_root' => $this->config->getStr('local.web_root'), ]; $subDir = $input->getArgument('subdir'); diff --git a/src/Command/Local/LocalDrushAliasesCommand.php b/src/Command/Local/LocalDrushAliasesCommand.php index bbdb2d7a63..e27a70647e 100644 --- a/src/Command/Local/LocalDrushAliasesCommand.php +++ b/src/Command/Local/LocalDrushAliasesCommand.php @@ -1,76 +1,86 @@ setName('local:drush-aliases') - ->setAliases(['drush-aliases']) ->addOption('recreate', 'r', InputOption::VALUE_NONE, 'Recreate the aliases.') ->addOption('group', 'g', InputOption::VALUE_REQUIRED, 'Recreate the aliases with a new group name.') - ->addOption('pipe', null, InputOption::VALUE_NONE, 'Output the current group name (do nothing else).') - ->setDescription('Find the project\'s Drush aliases'); + ->addOption('pipe', null, InputOption::VALUE_NONE, 'Output the current group name (do nothing else).'); $this->addExample('Change the alias group to @example', '-g example'); } - public function isHidden() + public function isHidden(): bool { if (parent::isHidden()) { return true; } // Only show this command if drush_aliases are enabled. - if (!$this->config()->get('application.drush_aliases')) { + if (!$this->config->getBool('application.drush_aliases')) { return true; } // Hide the command in the list while in a project directory, if the // project is not Drupal. // Avoid checking if running in the home directory. - $projectRoot = $this->getProjectRoot(); - if ($projectRoot && $this->config()->getHomeDirectory() !== getcwd() && !Drupal::isDrupal($projectRoot)) { + $projectRoot = $this->selector->getProjectRoot(); + if ($projectRoot && $this->config->getHomeDirectory() !== getcwd() && !Drupal::isDrupal($projectRoot)) { return true; } return false; } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $projectRoot = $this->getProjectRoot(); - $project = $this->getCurrentProject(); + $projectRoot = $this->selector->getProjectRoot(); + $project = $this->selector->getCurrentProject(); if (!$projectRoot || !$project) { throw new RootNotFoundException(); } - /** @var \Platformsh\Cli\Service\Drush $drush */ - $drush = $this->getService('drush'); - - $apps = $drush->getDrupalApps($projectRoot); + $apps = $this->drush->getDrupalApps($projectRoot); if (empty($apps)) { $this->stdErr->writeln('No Drupal applications found.'); return 1; } - $current_group = $drush->getAliasGroup($project, $projectRoot); + $current_group = $this->drush->getAliasGroup($project, $projectRoot); if ($input->getOption('pipe')) { $output->writeln($current_group); @@ -78,17 +88,17 @@ protected function execute(InputInterface $input, OutputInterface $output) return 0; } - if ($drush->getVersion() === false) { + if ($this->drush->getVersion() === false) { $this->stdErr->writeln('Drush is not installed, or the Drush version could not be determined.'); return 1; } if ($input->isInteractive()) { - $this->migrateAliasFiles($drush); + $this->migrateAliasFiles(); } - $aliases = $drush->getAliases($current_group); - $new_group = ltrim($input->getOption('group'), '@'); + $aliases = $this->drush->getAliases($current_group); + $new_group = ltrim((string) $input->getOption('group'), '@'); if (empty($aliases) && !$new_group && $current_group === $project->id) { $new_group = (new Slugify())->slugify($project->title); } @@ -98,51 +108,41 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln("Creating Drush aliases in the group @$new_group"); - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - if ($new_group !== $current_group) { - $existing = $drush->getAliases($new_group); + $existing = $this->drush->getAliases($new_group); if (!empty($existing)) { $question = "The Drush alias group @$new_group already exists. Overwrite?"; - if (!$questionHelper->confirm($question)) { + if (!$this->questionHelper->confirm($question)) { return 1; } } - $drush->setAliasGroup($new_group, $projectRoot); + $this->drush->setAliasGroup($new_group, $projectRoot); } - $environments = $this->api()->getEnvironments($project, true, false); + $environments = $this->api->getEnvironments($project, true, false); // Attempt to find the absolute application root directory for // each Enterprise environment. This will be cached by the Drush // service ($drush), for use while generating aliases. - /** @var \Platformsh\Cli\Service\RemoteEnvVars $envVarsService */ - $envVarsService = $this->getService('remote_env_vars'); - /** @var \Platformsh\Cli\Service\Ssh $ssh */ - $ssh = $this->getService('ssh'); - /** @var \Platformsh\Cli\Service\SshDiagnostics $sshDiagnostics */ - $sshDiagnostics = $this->getService('ssh_diagnostics'); - /** @var \Platformsh\Cli\Service\Shell $shell */ - $shell = $this->getService('shell'); + $envVarsService = $this->remoteEnvVars; foreach ($environments as $environment) { // Cache the environment's deployment information. // This will at least be used for \Platformsh\Cli\Service\Drush::getSiteUrl(). - if (!$this->api()->hasCachedCurrentDeployment($environment) && $environment->isActive()) { - $this->debug('Fetching deployment information for environment: ' . $environment->id); + if (!$this->api->hasCachedCurrentDeployment($environment) && $environment->isActive()) { + $this->io->debug('Fetching deployment information for environment: ' . $environment->id); try { - $this->api()->getCurrentDeployment($environment); + $this->api->getCurrentDeployment($environment); } catch (BadResponseException $e) { - if ($e->getResponse() && $e->getResponse()->getStatusCode() === 400) { - $this->debug('The deployment is invalid: ' . $e->getMessage()); - } elseif ($e->getResponse() && $e->getResponse()->getStatusCode() === 404) { - $this->debug('Current deployment not found: ' . $e->getMessage()); + if ($e->getResponse()->getStatusCode() === 400) { + $this->io->debug('The deployment is invalid: ' . $e->getMessage()); + } elseif ($e->getResponse()->getStatusCode() === 404) { + $this->io->debug('Current deployment not found: ' . $e->getMessage()); } else { throw $e; } - } catch (EnvironmentStateException $_) { - $this->debug('Current deployment not found.'); + } catch (EnvironmentStateException) { + $this->io->debug('Current deployment not found.'); } } @@ -156,42 +156,42 @@ protected function execute(InputInterface $input, OutputInterface $output) continue; } try { - $appRoot = $envVarsService->getEnvVar('APP_DIR', new RemoteHost($sshUrl, $environment, $ssh, $shell, $sshDiagnostics)); - } catch (\Symfony\Component\Process\Exception\RuntimeException $e) { + $appRoot = $envVarsService->getEnvVar('APP_DIR', $this->hostFactory->remote($sshUrl, $environment)); + } catch (RuntimeException $e) { $this->stdErr->writeln(sprintf( 'Unable to find app root for environment %s, app %s', - $this->api()->getEnvironmentLabel($environment, 'comment'), - '' . $app->getName() . '' + $this->api->getEnvironmentLabel($environment, 'comment'), + '' . $app->getName() . '', )); $this->stdErr->writeln($e->getMessage()); continue; } if (!empty($appRoot)) { - $this->debug(sprintf('App root for %s: %s', $sshUrl, $appRoot)); - $drush->setCachedAppRoot($sshUrl, $appRoot); + $this->io->debug(sprintf('App root for %s: %s', $sshUrl, $appRoot)); + $this->drush->setCachedAppRoot($sshUrl, $appRoot); } } } - $drush->createAliases($project, $projectRoot, $environments, $current_group); + $this->drush->createAliases($project, $projectRoot, $environments, $current_group); - $this->ensureDrushConfig($drush); + $this->ensureDrushConfig(); if ($new_group !== $current_group && !empty($aliases)) { - if ($questionHelper->confirm("Delete old Drush alias group @$current_group?")) { - $drush->deleteOldAliases($current_group); + if ($this->questionHelper->confirm("Delete old Drush alias group @$current_group?")) { + $this->drush->deleteOldAliases($current_group); } } // Clear the Drush cache now that the aliases have been updated. - $drush->clearCache(); + $this->drush->clearCache(); // Read the new aliases. - $aliases = $drush->getAliases($new_group, true); + $aliases = $this->drush->getAliases($new_group, true); } if (!empty($aliases)) { - $this->stdErr->writeln('Drush aliases for ' . $this->api()->getProjectLabel($project) . ':'); + $this->stdErr->writeln('Drush aliases for ' . $this->api->getProjectLabel($project) . ':'); foreach (array_keys($aliases) as $name) { $output->writeln(' @' . ltrim($name, '@')); } @@ -201,22 +201,20 @@ protected function execute(InputInterface $input, OutputInterface $output) } /** - * Ensure that the .drush/drush.yml file has the right config. - * - * @param \Platformsh\Cli\Service\Drush $drush + * Ensures that the .drush/drush.yml file has the right config. */ - protected function ensureDrushConfig(Drush $drush) + protected function ensureDrushConfig(): void { - if (!is_dir($drush->getSiteAliasDir())) { + if (!is_dir($this->drush->getSiteAliasDir())) { return; } - $drushYml = $drush->getDrushDir() . '/drush.yml'; + $drushYml = $this->drush->getDrushDir() . '/drush.yml'; $drushConfig = []; if (file_exists($drushYml)) { - $drushConfig = (array) Yaml::parse(file_get_contents($drushYml)); + $drushConfig = (array) Yaml::parse((string) file_get_contents($drushYml)); } - $aliasPath = $drush->getSiteAliasDir(); + $aliasPath = $this->drush->getSiteAliasDir(); if (getenv('HOME')) { $aliasPath = str_replace(getenv('HOME') . '/', '${env.home}/', $aliasPath); } @@ -230,34 +228,27 @@ protected function ensureDrushConfig(Drush $drush) $drushConfig['drush']['paths']['alias-path'][] = $aliasPath; - /** @var \Platformsh\Cli\Service\Filesystem $fs */ - $fs = $this->getService('fs'); - $fs->writeFile($drushYml, Yaml::dump($drushConfig, 5)); + $this->filesystem->writeFile($drushYml, Yaml::dump($drushConfig, 5)); } } /** - * Migrate old alias file(s) from ~/.drush to ~/.drush/site-aliases. - * - * @param \Platformsh\Cli\Service\Drush $drush + * Migrates old alias file(s) from ~/.drush to ~/.drush/site-aliases. */ - protected function migrateAliasFiles(Drush $drush) + protected function migrateAliasFiles(): void { - $newDrushDir = $drush->getHomeDir() . '/.drush/site-aliases'; - $oldFilenames = $drush->getLegacyAliasFiles(); + $newDrushDir = $this->drush->getHomeDir() . '/.drush/site-aliases'; + $oldFilenames = $this->drush->getLegacyAliasFiles(); if (empty($oldFilenames)) { return; } - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $newDrushDirRelative = str_replace($drush->getHomeDir() . '/', '~/', $newDrushDir); + $newDrushDirRelative = str_replace($this->drush->getHomeDir() . '/', '~/', $newDrushDir); $confirmText = "Do you want to move your global Drush alias files from ~/.drush to $newDrushDirRelative?"; - if (!$questionHelper->confirm($confirmText)) { + if (!$this->questionHelper->confirm($confirmText)) { return; } - if (!file_exists($newDrushDir) && !mkdir($newDrushDir, 0755, true)) { + if (!file_exists($newDrushDir) && !mkdir($newDrushDir, 0o755, true)) { $this->stdErr->writeln(sprintf('Failed to create directory: %s', $newDrushDir)); $this->stdErr->writeln('The alias files have not been moved.'); return; diff --git a/src/Command/Metrics/AllMetricsCommand.php b/src/Command/Metrics/AllMetricsCommand.php index 063a06e78d..2bffc6a011 100644 --- a/src/Command/Metrics/AllMetricsCommand.php +++ b/src/Command/Metrics/AllMetricsCommand.php @@ -1,17 +1,25 @@ */ + private array $tableHeader = [ 'timestamp' => 'Timestamp', 'service' => 'Service', 'type' => 'Type', @@ -41,23 +49,23 @@ class AllMetricsCommand extends MetricsCommandBase 'tmp_inodes_percent' => '/tmp inodes %', ]; - private $defaultColumns = ['timestamp', 'service', 'cpu_percent', 'mem_percent', 'disk_percent', 'tmp_disk_percent']; + /** @var string[] */ + private array $defaultColumns = ['timestamp', 'service', 'cpu_percent', 'mem_percent', 'disk_percent', 'tmp_disk_percent']; + public function __construct(private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table) + { + parent::__construct(); + } - /** - * {@inheritdoc} - */ - protected function configure() + protected function configure(): void { - $this->setName('metrics:all') - ->setAliases(['metrics', 'met']) - ->setDescription('Show CPU, disk and memory metrics for an environment') - ->addOption('bytes', 'B', InputOption::VALUE_NONE, 'Show sizes in bytes'); + $this->addOption('bytes', 'B', InputOption::VALUE_NONE, 'Show sizes in bytes'); $this->addExample('Show metrics for the last ' . (new Duration())->humanize(self::DEFAULT_RANGE)); $this->addExample('Show metrics in five-minute intervals over the last hour', '-i 5m -r 1h'); $this->addExample('Show metrics for all SQL services', '--type mariadb,%sql'); - $this->addMetricsOptions() - ->addProjectOption() - ->addEnvironmentOption(); + $this->addMetricsOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); PropertyFormatter::configureInput($this->getDefinition()); } @@ -65,29 +73,25 @@ protected function configure() /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $timeSpec = $this->validateTimeInput($input); if ($timeSpec === false) { return 1; } - $this->chooseEnvFilter = $this->filterEnvsMaybeActive(); - $this->validateInput($input, false, true); - - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); + $selection = $this->selector->getSelection($input, new SelectorConfig(selectDefaultEnv: true, chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive())); - if (!$table->formatIsMachineReadable()) { - $this->displayEnvironmentHeader(); + if (!$this->table->formatIsMachineReadable()) { + $this->selector->ensurePrintedSelection($selection); } // Only request the metrics fields that will be displayed. // // The fields are the selected column names (according to the $table // service), filtered to only those that contain an underscore. - $fieldNames = array_filter($table->columnsToDisplay($this->tableHeader, $this->defaultColumns), function ($c) { return strpos($c, '_') !== false; }); - $values = $this->fetchMetrics($input, $timeSpec, $this->getSelectedEnvironment(), $fieldNames); + $fieldNames = array_filter($this->table->columnsToDisplay($this->tableHeader, $this->defaultColumns), fn($c): bool => str_contains((string) $c, '_')); + $values = $this->fetchMetrics($input, $timeSpec, $selection->getEnvironment(), $fieldNames); if ($values === false) { return 1; } @@ -118,22 +122,21 @@ protected function execute(InputInterface $input, OutputInterface $output) 'tmp_inodes_used' => new Field('tmp_inodes_used', Field::FORMAT_ROUNDED), 'tmp_inodes_limit' => new Field('tmp_inodes_used', Field::FORMAT_ROUNDED), 'tmp_inodes_percent' => new Field('tmp_inodes_percent', Field::FORMAT_PERCENT), - ]); + ], $selection->getEnvironment()); - if (!$table->formatIsMachineReadable()) { - /** @var PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); + if (!$this->table->formatIsMachineReadable()) { + $formatter = $this->propertyFormatter; $this->stdErr->writeln(\sprintf( 'Metrics at %s intervals from %s to %s:', (new Duration())->humanize($timeSpec->getInterval()), $formatter->formatDate($timeSpec->getStartTime()), - $formatter->formatDate($timeSpec->getEndTime()) + $formatter->formatDate($timeSpec->getEndTime()), )); } - $table->render($rows, $this->tableHeader, $this->defaultColumns); + $this->table->render($rows, $this->tableHeader, $this->defaultColumns); - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->explainHighMemoryServices(); $this->stdErr->writeln(''); $this->stdErr->writeln('You can run the cpu, disk and mem commands for more detail.'); diff --git a/src/Command/Metrics/CpuCommand.php b/src/Command/Metrics/CpuCommand.php index 9743da7218..97f4e7c7fd 100644 --- a/src/Command/Metrics/CpuCommand.php +++ b/src/Command/Metrics/CpuCommand.php @@ -1,16 +1,24 @@ */ + private array $tableHeader = [ 'timestamp' => 'Timestamp', 'service' => 'Service', 'type' => 'Type', @@ -19,19 +27,19 @@ class CpuCommand extends MetricsCommandBase 'percent' => 'Used %', ]; - private $defaultColumns = ['timestamp', 'service', 'used', 'limit', 'percent']; + /** @var string[] */ + private array $defaultColumns = ['timestamp', 'service', 'used', 'limit', 'percent']; + public function __construct(private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table) + { + parent::__construct(); + } - /** - * {@inheritdoc} - */ - protected function configure() + protected function configure(): void { - $this->setName('metrics:cpu') - ->setAliases(['cpu']) - ->setDescription('Show CPU usage of an environment'); - $this->addMetricsOptions() - ->addProjectOption() - ->addEnvironmentOption(); + $this->addMetricsOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); PropertyFormatter::configureInput($this->getDefinition()); } @@ -39,23 +47,20 @@ protected function configure() /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $timeSpec = $this->validateTimeInput($input); if ($timeSpec === false) { return 1; } - $this->validateInput($input, false, true); - - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); + $selection = $this->selector->getSelection($input, new SelectorConfig(selectDefaultEnv: true)); - if (!$table->formatIsMachineReadable()) { - $this->displayEnvironmentHeader(); + if (!$this->table->formatIsMachineReadable()) { + $this->selector->ensurePrintedSelection($selection); } - $values = $this->fetchMetrics($input, $timeSpec, $this->getSelectedEnvironment(), ['cpu_used', 'cpu_percent', 'cpu_limit']); + $values = $this->fetchMetrics($input, $timeSpec, $selection->getEnvironment(), ['cpu_used', 'cpu_percent', 'cpu_limit']); if ($values === false) { return 1; } @@ -64,20 +69,19 @@ protected function execute(InputInterface $input, OutputInterface $output) 'used' => new Field('cpu_used', Field::FORMAT_ROUNDED_2DP), 'limit' => new Field('cpu_limit', Field::FORMAT_ROUNDED_2DP), 'percent' => new Field('cpu_percent', Field::FORMAT_PERCENT), - ]); + ], $selection->getEnvironment()); - if (!$table->formatIsMachineReadable()) { - /** @var PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); + if (!$this->table->formatIsMachineReadable()) { + $formatter = $this->propertyFormatter; $this->stdErr->writeln(\sprintf( 'Average CPU usage at %s intervals from %s to %s:', (new Duration())->humanize($timeSpec->getInterval()), $formatter->formatDate($timeSpec->getStartTime()), - $formatter->formatDate($timeSpec->getEndTime()) + $formatter->formatDate($timeSpec->getEndTime()), )); } - $table->render($rows, $this->tableHeader, $this->defaultColumns); + $this->table->render($rows, $this->tableHeader, $this->defaultColumns); return 0; } diff --git a/src/Command/Metrics/CurlCommand.php b/src/Command/Metrics/CurlCommand.php index c4a671b375..063dbeec39 100644 --- a/src/Command/Metrics/CurlCommand.php +++ b/src/Command/Metrics/CurlCommand.php @@ -1,41 +1,43 @@ setName('metrics:curl') - ->setDescription("Run an authenticated cURL request on an environment's metrics API"); + parent::__construct(); + } + protected function configure(): void + { CurlCli::configureInput($this->getDefinition()); - $this->addProjectOption(); - $this->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input, false, true); - - // Initialize the API service so that it gets CommandBase's event listeners - // (allowing for auto login). - $this->api(); + $selection = $this->selector->getSelection($input, new SelectorConfig(selectDefaultEnv: true)); - $link = $this->getMetricsLink($this->getSelectedEnvironment()); + $link = $this->getMetricsLink($selection->getEnvironment()); if (!$link) { return 1; } - /** @var CurlCli $curl */ - $curl = $this->getService('curl_cli'); - - return $curl->run($link['href'], $input, $output); + return $this->curlCli->run($link['href'], $input, $output); } } diff --git a/src/Command/Metrics/DiskUsageCommand.php b/src/Command/Metrics/DiskUsageCommand.php index b206e9fc04..841795382c 100644 --- a/src/Command/Metrics/DiskUsageCommand.php +++ b/src/Command/Metrics/DiskUsageCommand.php @@ -1,17 +1,25 @@ */ + private array $tableHeader = [ 'timestamp' => 'Timestamp', 'service' => 'Service', 'type' => 'Type', @@ -28,22 +36,24 @@ class DiskUsageCommand extends MetricsCommandBase 'tmp_ilimit' => '/tmp inodes limit', 'tmp_ipercent' => '/tmp inodes %', ]; - private $defaultColumns = ['timestamp', 'service', 'used', 'limit', 'percent', 'ipercent', 'tmp_percent']; - private $tmpReportColumns = ['timestamp', 'service', 'tmp_used', 'tmp_limit', 'tmp_percent', 'tmp_ipercent']; + /** @var string[] */ + private array $defaultColumns = ['timestamp', 'service', 'used', 'limit', 'percent', 'ipercent', 'tmp_percent']; + /** @var string[] */ + private array $tmpReportColumns = ['timestamp', 'service', 'tmp_used', 'tmp_limit', 'tmp_percent', 'tmp_ipercent']; - /** - * {@inheritdoc} - */ - protected function configure() + public function __construct(private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table) { - $this->setName('metrics:disk-usage') - ->setAliases(['disk']) - ->setDescription('Show disk usage of an environment') - ->addOption('bytes', 'B', InputOption::VALUE_NONE, 'Show sizes in bytes') - ->addMetricsOptions() - ->addOption('tmp', null, InputOption::VALUE_NONE, 'Report temporary disk usage (shows columns: ' . implode(', ', $this->tmpReportColumns) . ')') - ->addProjectOption() - ->addEnvironmentOption(); + parent::__construct(); + } + + protected function configure(): void + { + $this->addOption('bytes', 'B', InputOption::VALUE_NONE, 'Show sizes in bytes') + ->addOption('tmp', null, InputOption::VALUE_NONE, 'Report temporary disk usage (shows columns: ' . implode(', ', $this->tmpReportColumns) . ')'); + $this->addMetricsOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); PropertyFormatter::configureInput($this->getDefinition()); } @@ -51,7 +61,7 @@ protected function configure() /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $timeSpec = $this->validateTimeInput($input); if ($timeSpec === false) { @@ -61,18 +71,15 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($input->getOption('tmp')) { $input->setOption('columns', $this->tmpReportColumns); } + $this->table->removeDeprecatedColumns(['interval'], '', $input, $output); - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - $table->removeDeprecatedColumns(['interval'], '', $input, $output); - - $this->validateInput($input, false, true); + $selection = $this->selector->getSelection($input, new SelectorConfig(selectDefaultEnv: true)); - if (!$table->formatIsMachineReadable()) { - $this->displayEnvironmentHeader(); + if (!$this->table->formatIsMachineReadable()) { + $this->selector->ensurePrintedSelection($selection); } - $values = $this->fetchMetrics($input, $timeSpec, $this->getSelectedEnvironment(), ['disk_used', 'disk_percent', 'disk_limit', 'inodes_used', 'inodes_percent', 'inodes_limit']); + $values = $this->fetchMetrics($input, $timeSpec, $selection->getEnvironment(), ['disk_used', 'disk_percent', 'disk_limit', 'inodes_used', 'inodes_percent', 'inodes_limit']); if ($values === false) { return 1; } @@ -95,21 +102,20 @@ protected function execute(InputInterface $input, OutputInterface $output) 'tmp_iused' => new Field('tmp_inodes_used', Field::FORMAT_ROUNDED), 'tmp_ilimit' => new Field('tmp_inodes_used', Field::FORMAT_ROUNDED), 'tmp_ipercent' => new Field('tmp_inodes_percent', Field::FORMAT_PERCENT), - ]); + ], $selection->getEnvironment()); - if (!$table->formatIsMachineReadable()) { - /** @var PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); + if (!$this->table->formatIsMachineReadable()) { + $formatter = $this->propertyFormatter; $this->stdErr->writeln(\sprintf( 'Average %s at %s intervals from %s to %s:', $input->getOption('tmp') ? 'temporary disk usage' : 'disk usage', (new Duration())->humanize($timeSpec->getInterval()), $formatter->formatDate($timeSpec->getStartTime()), - $formatter->formatDate($timeSpec->getEndTime()) + $formatter->formatDate($timeSpec->getEndTime()), )); } - $table->render($rows, $this->tableHeader, $this->defaultColumns); + $this->table->render($rows, $this->tableHeader, $this->defaultColumns); return 0; } diff --git a/src/Command/Metrics/MemCommand.php b/src/Command/Metrics/MemCommand.php index c109b44b33..007434c2fa 100644 --- a/src/Command/Metrics/MemCommand.php +++ b/src/Command/Metrics/MemCommand.php @@ -1,17 +1,25 @@ */ + private array $tableHeader = [ 'timestamp' => 'Timestamp', 'service' => 'Service', 'type' => 'Type', @@ -20,20 +28,20 @@ class MemCommand extends MetricsCommandBase 'percent' => 'Used %', ]; - private $defaultColumns = ['timestamp', 'service', 'used', 'limit', 'percent']; + /** @var string[] */ + private array $defaultColumns = ['timestamp', 'service', 'used', 'limit', 'percent']; + public function __construct(private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table) + { + parent::__construct(); + } - /** - * {@inheritdoc} - */ - protected function configure() + protected function configure(): void { - $this->setName('metrics:memory') - ->setAliases(['mem', 'memory']) - ->setDescription('Show memory usage of an environment') - ->addOption('bytes', 'B', InputOption::VALUE_NONE, 'Show sizes in bytes'); - $this->addMetricsOptions() - ->addProjectOption() - ->addEnvironmentOption(); + $this->addOption('bytes', 'B', InputOption::VALUE_NONE, 'Show sizes in bytes'); + $this->addMetricsOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); PropertyFormatter::configureInput($this->getDefinition()); } @@ -41,23 +49,20 @@ protected function configure() /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $timeSpec = $this->validateTimeInput($input); if ($timeSpec === false) { return 1; } - $this->validateInput($input, false, true); - - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); + $selection = $this->selector->getSelection($input, new SelectorConfig(selectDefaultEnv: true)); - if (!$table->formatIsMachineReadable()) { - $this->displayEnvironmentHeader(); + if (!$this->table->formatIsMachineReadable()) { + $this->selector->ensurePrintedSelection($selection); } - $values = $this->fetchMetrics($input, $timeSpec, $this->getSelectedEnvironment(), ['mem_used', 'mem_percent', 'mem_limit']); + $values = $this->fetchMetrics($input, $timeSpec, $selection->getEnvironment(), ['mem_used', 'mem_percent', 'mem_limit']); if ($values === false) { return 1; } @@ -68,22 +73,21 @@ protected function execute(InputInterface $input, OutputInterface $output) 'used' => new Field('mem_used', $bytes ? Field::FORMAT_ROUNDED : Field::FORMAT_MEMORY), 'limit' => new Field('mem_limit', $bytes ? Field::FORMAT_ROUNDED : Field::FORMAT_MEMORY), 'percent' => new Field('mem_percent', Field::FORMAT_PERCENT), - ]); + ], $selection->getEnvironment()); - if (!$table->formatIsMachineReadable()) { - /** @var PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); + if (!$this->table->formatIsMachineReadable()) { + $formatter = $this->propertyFormatter; $this->stdErr->writeln(\sprintf( 'Average memory usage at %s intervals from %s to %s:', (new Duration())->humanize($timeSpec->getInterval()), $formatter->formatDate($timeSpec->getStartTime()), - $formatter->formatDate($timeSpec->getEndTime()) + $formatter->formatDate($timeSpec->getEndTime()), )); } - $table->render($rows, $this->tableHeader, $this->defaultColumns); + $this->table->render($rows, $this->tableHeader, $this->defaultColumns); - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->explainHighMemoryServices(); } diff --git a/src/Command/Metrics/MetricsCommandBase.php b/src/Command/Metrics/MetricsCommandBase.php index 0feae54798..51521e746c 100644 --- a/src/Command/Metrics/MetricsCommandBase.php +++ b/src/Command/Metrics/MetricsCommandBase.php @@ -1,8 +1,17 @@ > */ + private array $fields = [ // Grid. 'local' => [ 'cpu_used' => "AVG(SUM((`cpu.user` + `cpu.kernel`) / `interval`, 'service', 'instance'), 'service')", @@ -86,37 +101,51 @@ abstract class MetricsCommandBase extends CommandBase 'inodes_limit' => "AVG(`disk.inodes.limit`, 'mountpoint')", ], ]; + #[Required] + public function autowire(Api $api, Config $config, Io $io, PropertyFormatter $propertyFormatter): void + { + $this->api = $api; + $this->config = $config; + $this->propertyFormatter = $propertyFormatter; + $this->io = $io; + } - public function isEnabled() + public function isEnabled(): bool { - if (!$this->config()->getWithDefault('api.metrics', false)) { + if (!$this->config->getBool('api.metrics')) { return false; } return parent::isEnabled(); } - protected function addMetricsOptions() + protected function addMetricsOptions(): self { $duration = new Duration(); - $this->addOption('range', 'r', InputOption::VALUE_REQUIRED, + $this->addOption( + 'range', + 'r', + InputOption::VALUE_REQUIRED, 'The time range. Metrics will be loaded for this duration until the end time (--to).' . "\n" . 'You can specify units: hours (h), minutes (m), or seconds (s).' . "\n" . \sprintf( 'Minimum %s, maximum 8h or more (depending on the project), default %s.', $duration->humanize(self::MIN_RANGE), - $duration->humanize(self::DEFAULT_RANGE) - ) + $duration->humanize(self::DEFAULT_RANGE), + ), ); // The $default is left at null so the lack of input can be detected. - $this->addOption('interval', 'i', InputOption::VALUE_REQUIRED, + $this->addOption( + 'interval', + 'i', + InputOption::VALUE_REQUIRED, 'The time interval. Defaults to a division of the range.' . "\n" . 'You can specify units: hours (h), minutes (m), or seconds (s).' - . "\n" . \sprintf('Minimum %s.', $duration->humanize(self::MIN_INTERVAL)) + . "\n" . \sprintf('Minimum %s.', $duration->humanize(self::MIN_INTERVAL)), ); $this->addOption('to', null, InputOption::VALUE_REQUIRED, 'The end time. Defaults to now.'); $this->addOption('latest', '1', InputOption::VALUE_NONE, 'Show only the latest single data point'); - $this->addOption('service', 's', InputOption::VALUE_REQUIRED|InputOption::VALUE_IS_ARRAY, 'Filter by service or application name' . "\n" . Wildcard::HELP); - $this->addOption('type', null, InputOption::VALUE_REQUIRED|InputOption::VALUE_IS_ARRAY, 'Filter by service type (if --service is not provided). The version is not required.' . "\n" . Wildcard::HELP); + $this->addOption('service', 's', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter by service or application name' . "\n" . Wildcard::HELP); + $this->addOption('type', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter by service type (if --service is not provided). The version is not required.' . "\n" . Wildcard::HELP); return $this; } @@ -126,16 +155,16 @@ protected function addMetricsOptions() * @return array{'href': string, 'collection': string}|false * The link data or false on failure. */ - protected function getMetricsLink(Environment $environment) + protected function getMetricsLink(Environment $environment): false|array { $environmentData = $environment->getData(); if (!isset($environmentData['_links']['#metrics'])) { - $this->stdErr->writeln(\sprintf('The metrics API is not currently available on the environment: %s', $this->api()->getEnvironmentLabel($environment, 'error'))); + $this->stdErr->writeln(\sprintf('The metrics API is not currently available on the environment: %s', $this->api->getEnvironmentLabel($environment, 'error'))); return false; } if (!isset($environmentData['_links']['#metrics'][0]['href'], $environmentData['_links']['#metrics'][0]['collection'])) { - $this->stdErr->writeln(\sprintf('Unable to find metrics URLs for the environment: %s', $this->api()->getEnvironmentLabel($environment, 'error'))); + $this->stdErr->writeln(\sprintf('Unable to find metrics URLs for the environment: %s', $this->api->getEnvironmentLabel($environment, 'error'))); return false; } @@ -149,7 +178,7 @@ protected function getMetricsLink(Environment $environment) * @param string $dimension * @return array */ - private function dimensionFields($dimension) + private function dimensionFields(string $dimension): array { $fields = ['service' => '', 'mountpoint' => '', 'instance' => '']; foreach (explode('/', $dimension) as $field) { @@ -170,10 +199,10 @@ private function dimensionFields($dimension) * @param string[] $fieldNames * An array of field names, which map to queries in $this->fields. * - * @return false|array + * @return false|array>>> * False on failure, or an array of sketch values, keyed by: time, service, dimension, and name. */ - protected function fetchMetrics(InputInterface $input, TimeSpec $timeSpec, Environment $environment, $fieldNames) + protected function fetchMetrics(InputInterface $input, TimeSpec $timeSpec, Environment $environment, array $fieldNames): array|false { $link = $this->getMetricsLink($environment); if (!$link) { @@ -190,20 +219,18 @@ protected function fetchMetrics(InputInterface $input, TimeSpec $timeSpec, Envir $deploymentType = $this->getDeploymentType($environment); if (!isset($this->fields[$deploymentType])) { - if (($fallback = key($this->fields)) === false) { - throw new \InvalidArgumentException('No query fields are defined'); - } + $fallback = key($this->fields); $this->stdErr->writeln(sprintf( 'No query fields are defined for the deployment type: %s. Falling back to: %s', $deploymentType, - $fallback + $fallback, )); $deploymentType = $fallback; } // Add fields and expressions to the query based on the requested $fieldNames. - $fieldNames = array_map(function ($f) { - if (substr($f, 0, 4) === 'tmp_') { + $fieldNames = array_map(function ($f): string { + if (str_starts_with($f, 'tmp_')) { return substr($f, 4); } return $f; @@ -215,7 +242,7 @@ protected function fetchMetrics(InputInterface $input, TimeSpec $timeSpec, Envir } // Select services based on the --service or --type options. - $deployment = $this->api()->getCurrentDeployment($environment); + $deployment = $this->api->getCurrentDeployment($environment); $allServices = array_merge($deployment->webapps, $deployment->services, $deployment->workers); $servicesInput = ArrayArgument::getOption($input, 'service'); $selectedServiceNames = []; @@ -229,7 +256,7 @@ protected function fetchMetrics(InputInterface $input, TimeSpec $timeSpec, Envir $byType = []; foreach ($allServices as $name => $service) { $type = $service->type; - list($prefix) = explode(':', $service->type, 2); + [$prefix] = explode(':', $service->type, 2); $byType[$type][] = $name; $byType[$prefix][] = $name; } @@ -244,19 +271,21 @@ protected function fetchMetrics(InputInterface $input, TimeSpec $timeSpec, Envir $selectedServiceNames = array_unique($selectedServiceNames); } if (!empty($selectedServiceNames)) { - $this->debug('Selected service(s): ' . implode(', ', $selectedServiceNames)); + $this->io->debug('Selected service(s): ' . implode(', ', $selectedServiceNames)); if (count($selectedServiceNames) === 1) { $query->addFilter('service', reset($selectedServiceNames)); } } if ($this->stdErr->isDebug()) { - $this->debug('Metrics query: ' . json_encode($query->asArray(), JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES)); + $this->io->debug('Metrics query: ' . json_encode($query->asArray(), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); } // Perform the metrics query. - $client = $this->api()->getHttpClient(); - $request = $client->createRequest('POST', $metricsQueryUrl, ['json' => $query->asArray()]); + $client = $this->api->getHttpClient(); + $request = new Request('POST', $metricsQueryUrl, [ + 'Content-Type' => 'application/json', + ], json_encode($query->asArray(), JSON_THROW_ON_ERROR)); try { $result = $client->send($request); } catch (BadResponseException $e) { @@ -276,7 +305,7 @@ protected function fetchMetrics(InputInterface $input, TimeSpec $timeSpec, Envir $values = []; foreach ($items as $item) { $time = $item['point']['timestamp']; - $dimension = isset($item['point']['dimension']) ? $item['point']['dimension'] : ''; + $dimension = $item['point']['dimension'] ?? ''; $dimensionFields = $this->dimensionFields($dimension); $service = $dimensionFields['service']; // Skip the router service by default (if no services are selected). @@ -292,7 +321,10 @@ protected function fetchMetrics(InputInterface $input, TimeSpec $timeSpec, Envir if (isset($values[$time][$service][$dimension][$fieldPrefix . $name])) { $this->stdErr->writeln(\sprintf( 'Warning: duplicate value found for time %s, service %s, dimension %s, field %s', - $time, $service, $dimension, $fieldPrefix . $name + $time, + $service, + $dimension, + $fieldPrefix . $name, )); } else { $values[$time][$service][$dimension][$fieldPrefix . $name] = Sketch::fromApiValue($value); @@ -335,7 +367,7 @@ protected function fetchMetrics(InputInterface $input, TimeSpec $timeSpec, Envir * * @return TimeSpec|false */ - protected function validateTimeInput(InputInterface $input) + protected function validateTimeInput(InputInterface $input): false|TimeSpec { $interval = null; if ($intervalStr = $input->getOption('interval')) { @@ -352,7 +384,7 @@ protected function validateTimeInput(InputInterface $input) } if ($to = $input->getOption('to')) { - $endTime = \strtotime($to); + $endTime = \strtotime((string) $to); if (!$endTime) { $this->stdErr->writeln('Failed to parse --to time: ' . $to); return false; @@ -381,7 +413,7 @@ protected function validateTimeInput(InputInterface $input) 'The --interval %s is too short relative to the --range (%s): the maximum number of intervals is %d.', (new Duration())->humanize($interval), (new Duration())->humanize($rangeSeconds), - self::MAX_INTERVALS + self::MAX_INTERVALS, )); return false; } @@ -402,12 +434,12 @@ protected function validateTimeInput(InputInterface $input) * * @return int */ - private function defaultInterval($range) + private function defaultInterval(int $range): int { $divisor = 5; // Number of points per time range. // Number of seconds to round to: $granularity = 10; - foreach ([3600*24, 3600*6, 3600*3, 3600, 600, 300, 60, 30] as $level) { + foreach ([3600 * 24, 3600 * 6, 3600 * 3, 3600, 600, 300, 60, 30] as $level) { if ($range >= $level * $divisor) { $granularity = $level; break; @@ -423,11 +455,8 @@ private function defaultInterval($range) /** * Returns the deployment type of an environment (needed for differing queries). - * - * @param Environment $environment - * @return string */ - private function getDeploymentType(Environment $environment) + private function getDeploymentType(Environment $environment): string { if (in_array($environment->deployment_target, ['local', 'enterprise', 'dedicated'])) { return $environment->deployment_target; @@ -442,20 +471,17 @@ private function getDeploymentType(Environment $environment) /** * Builds metrics table rows. * - * @param array $values + * @param array>>> $values * An array of values from fetchMetrics(). * @param array $fields * An array of fields keyed by column name. * - * @return array + * @return array|TableSeparator> * Table rows. */ - protected function buildRows(array $values, $fields) + protected function buildRows(array $values, array $fields, Environment $environment): array { - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - - $deployment = $this->api()->getCurrentDeployment($this->getSelectedEnvironment()); + $deployment = $this->api->getCurrentDeployment($environment); // Create a closure which can sort services by name, putting apps and // workers first. @@ -464,9 +490,9 @@ protected function buildRows(array $values, $fields) $serviceNames = array_keys($deployment->services); sort($serviceNames, SORT_NATURAL); $nameOrder = array_flip(array_merge($appAndWorkerNames, $serviceNames, ['router'])); - $sortServices = function ($a, $b) use ($nameOrder) { - $aPos = isset($nameOrder[$a]) ? $nameOrder[$a] : 1000; - $bPos = isset($nameOrder[$b]) ? $nameOrder[$b] : 1000; + $sortServices = function ($a, $b) use ($nameOrder): int { + $aPos = $nameOrder[$a] ?? 1000; + $bPos = $nameOrder[$b] ?? 1000; return $aPos > $bPos ? 1 : ($aPos < $bPos ? -1 : 0); }; @@ -478,7 +504,7 @@ protected function buildRows(array $values, $fields) $rows[] = new TableSeparator(); } $startCount = count($rows); - $formattedTimestamp = $formatter->formatDate($timestamp); + $formattedTimestamp = $this->propertyFormatter->formatDate($timestamp); uksort($byService, $sortServices); foreach ($byService as $service => $byDimension) { if (isset($deployment->services[$service])) { @@ -496,7 +522,7 @@ protected function buildRows(array $values, $fields) $row = []; $row['timestamp'] = new AdaptiveTableCell($formattedTimestamp, ['wrap' => false]); $row['service'] = $service; - $row['type'] = $formatter->format($type, 'service_type'); + $row['type'] = $this->propertyFormatter->format($type, 'service_type'); foreach ($fields as $columnName => $field) { /** @var Field $field */ $fieldName = $field->getName(); @@ -529,10 +555,10 @@ protected function buildRows(array $values, $fields) /** * Merges table rows per service to reduce unnecessary empty cells. * - * @param array $rows - * @return array + * @param array|TableSeparator> $rows + * @return array|TableSeparator> */ - private function mergeRows(array $rows) + private function mergeRows(array $rows): array { $infoKeys = array_flip(['service', 'timestamp', 'instance', 'type']); $previous = $previousKey = null; @@ -551,26 +577,10 @@ private function mergeRows(array $rows) return $rows; } - /** - * Displays the current project and environment, if not already displayed. - * - * @return void - */ - protected function displayEnvironmentHeader() - { - if (!$this->printedSelectedEnvironment) { - $this->stdErr->writeln('Selected project: ' . $this->api()->getProjectLabel($this->getSelectedProject())); - $this->stdErr->writeln('Selected environment: ' . $this->api()->getEnvironmentLabel($this->getSelectedEnvironment())); - } - $this->stdErr->writeln(''); - } - /** * Shows an explanation if services were found that use high memory. - * - * @return void */ - protected function explainHighMemoryServices() + protected function explainHighMemoryServices(): void { if ($this->foundHighMemoryServices) { $this->stdErr->writeln(''); diff --git a/src/Command/Mount/MountDownloadCommand.php b/src/Command/Mount/MountDownloadCommand.php index db78d82b68..23e0b5bbd0 100644 --- a/src/Command/Mount/MountDownloadCommand.php +++ b/src/Command/Mount/MountDownloadCommand.php @@ -1,54 +1,65 @@ setName('mount:download') - ->setDescription('Download files from a mount, using rsync') ->addOption('all', 'a', InputOption::VALUE_NONE, 'Download from all mounts') ->addOption('mount', 'm', InputOption::VALUE_REQUIRED, 'The mount (as an app-relative path)') ->addOption('target', null, InputOption::VALUE_REQUIRED, 'The directory to which files will be downloaded. If --all is used, the mount path will be appended') ->addOption('source-path', null, InputOption::VALUE_NONE, "Use the mount's source path (rather than the mount path) as a subdirectory of the target, when --all is used") ->addOption('delete', null, InputOption::VALUE_NONE, 'Whether to delete extraneous files in the target directory') - ->addOption('exclude', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_REQUIRED, 'File(s) to exclude from the download (pattern)') - ->addOption('include', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_REQUIRED, 'File(s) not to exclude (pattern)') + ->addOption('exclude', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'File(s) to exclude from the download (pattern)') + ->addOption('include', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'File(s) not to exclude (pattern)') ->addOption('refresh', null, InputOption::VALUE_NONE, 'Whether to refresh the cache'); - $this->addProjectOption(); - $this->addEnvironmentOption(); - $this->addRemoteContainerOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->selector->addRemoteContainerOptions($this->getDefinition()); + $this->addCompleter($this->selector); Ssh::configureInput($this->getDefinition()); } /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); - - /** @var App $container */ - $container = $this->selectRemoteContainer($input); - /** @var \Platformsh\Cli\Service\Mount $mountService */ - $mountService = $this->getService('mount'); - $mounts = $mountService->mountsFromConfig($container->getConfig()); + $selection = $this->selector->getSelection($input, new SelectorConfig(chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive())); + $container = $selection->getRemoteContainer(); + $mounts = $this->mount->mountsFromConfig($container->getConfig()); $sshUrl = $container->getSshUrl($input->getOption('instance')); if (empty($mounts)) { @@ -57,11 +68,6 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - /** @var \Platformsh\Cli\Service\Filesystem $fs */ - $fs = $this->getService('fs'); - $all = $input->getOption('all'); if ($input->getOption('mount')) { @@ -71,7 +77,7 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - $mountPath = $mountService->matchMountPath($input->getOption('mount'), $mounts); + $mountPath = $this->mount->matchMountPath($input->getOption('mount'), $mounts); } elseif (!$all && $input->isInteractive()) { $mountOptions = []; foreach ($mounts as $path => $definition) { @@ -83,9 +89,9 @@ protected function execute(InputInterface $input, OutputInterface $output) } $mountOptions['\\ALL'] = 'All mounts'; - $choice = $questionHelper->choose( + $choice = $this->questionHelper->choose( $mountOptions, - 'Enter a number to choose a mount to download from:' + 'Enter a number to choose a mount to download from:', ); if ($choice === '\\ALL') { $all = true; @@ -107,11 +113,11 @@ protected function execute(InputInterface $input, OutputInterface $output) $questionText = 'Target directory'; $defaultTarget = isset($mountPath) ? $this->getDefaultTarget($container, $mountPath) : '.'; if ($defaultTarget !== null) { - $formattedDefaultTarget = $fs->formatPathForDisplay($defaultTarget); + $formattedDefaultTarget = $this->filesystem->formatPathForDisplay($defaultTarget); $questionText .= ' [' . $formattedDefaultTarget . ']'; } $questionText .= ': '; - $target = $questionHelper->ask($input, $this->stdErr, new Question($questionText, $defaultTarget)); + $target = $this->questionHelper->ask($input, $this->stdErr, new Question($questionText, $defaultTarget)); $this->stdErr->writeln(''); } @@ -124,17 +130,14 @@ protected function execute(InputInterface $input, OutputInterface $output) if (!file_exists($target)) { // Allow rsync to create the target directory if it doesn't // already exist. - if (!$questionHelper->confirm(sprintf('Directory not found: %s. Do you want to create it?', $target))) { + if (!$this->questionHelper->confirm(sprintf('Directory not found: %s. Do you want to create it?', $target))) { return 1; } $this->stdErr->writeln(''); } else { - $fs->validateDirectory($target, true); + $this->filesystem->validateDirectory($target, true); } - /** @var \Platformsh\Cli\Service\Rsync $rsync */ - $rsync = $this->getService('rsync'); - $rsyncOptions = [ 'delete' => $input->getOption('delete'), 'exclude' => $input->getOption('exclude'), @@ -147,9 +150,9 @@ protected function execute(InputInterface $input, OutputInterface $output) $confirmText = sprintf( 'Downloading files from all remote mounts to %s' . "\n\nAre you sure you want to continue?", - $fs->formatPathForDisplay($target) + $this->filesystem->formatPathForDisplay($target), ); - if (!$questionHelper->confirm($confirmText)) { + if (!$this->questionHelper->confirm($confirmText)) { return 1; } @@ -160,7 +163,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $mountSpecificTarget = $target . '/' . $mountPath; if ($useSourcePath) { if (isset($definition['source_path'])) { - $mountSpecificTarget = $target . '/' . trim($definition['source_path'], '/'); + $mountSpecificTarget = $target . '/' . trim((string) $definition['source_path'], '/'); } else { $this->stdErr->writeln('No source path defined for mount ' . $mountPath . ''); } @@ -168,24 +171,24 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(sprintf( 'Downloading files from %s to %s', $mountPath, - $fs->formatPathForDisplay($mountSpecificTarget) + $this->filesystem->formatPathForDisplay($mountSpecificTarget), )); - $fs->mkdir($mountSpecificTarget); - $rsync->syncDown($sshUrl, $mountPath, $mountSpecificTarget, $rsyncOptions); + $this->filesystem->mkdir($mountSpecificTarget); + $this->rsync->syncDown($sshUrl, $mountPath, $mountSpecificTarget, $rsyncOptions); } } elseif (isset($mountPath)) { $confirmText = sprintf( 'Downloading files from the remote mount %s to %s' . "\n\nAre you sure you want to continue?", $mountPath, - $fs->formatPathForDisplay($target) + $this->filesystem->formatPathForDisplay($target), ); - if (!$questionHelper->confirm($confirmText)) { + if (!$this->questionHelper->confirm($confirmText)) { return 1; } $this->stdErr->writeln(''); - $rsync->syncDown($sshUrl, $mountPath, $target, $rsyncOptions); + $this->rsync->syncDown($sshUrl, $mountPath, $target, $rsyncOptions); } else { throw new \LogicException('Mount path not defined'); } @@ -193,24 +196,19 @@ protected function execute(InputInterface $input, OutputInterface $output) return 0; } - /** - * @param \Platformsh\Cli\Model\RemoteContainer\App $app - * @param string $mountPath - * - * @return string|null - */ - private function getDefaultTarget(App $app, $mountPath) + private function getDefaultTarget(RemoteContainerInterface $app, string $mountPath): ?string { - /** @var \Platformsh\Cli\Service\Mount $mountService */ - $mountService = $this->getService('mount'); + if (!$app instanceof App) { + return null; + } $appPath = $this->getLocalAppPath($app); if ($appPath !== null && is_dir($appPath . '/' . $mountPath)) { return $appPath . '/' . $mountPath; } - $mounts = $mountService->mountsFromConfig($app->getConfig()); - $sharedMounts = $mountService->getSharedFileMounts($mounts); + $mounts = $this->mount->mountsFromConfig($app->getConfig()); + $sharedMounts = $this->mount->getSharedFileMounts($mounts); if (isset($sharedMounts[$mountPath])) { $sharedDir = $this->getSharedDir($app); if ($sharedDir !== null && file_exists($sharedDir . '/' . $sharedMounts[$mountPath])) { @@ -224,13 +222,12 @@ private function getDefaultTarget(App $app, $mountPath) /** * @return LocalApplication[] */ - private function getLocalApps() + private function getLocalApps(): array { if (!isset($this->localApps)) { $this->localApps = []; - if ($projectRoot = $this->getProjectRoot()) { - /** @var \Platformsh\Cli\Local\ApplicationFinder $finder */ - $finder = $this->getService('app_finder'); + if ($projectRoot = $this->selector->getProjectRoot()) { + $finder = $this->applicationFinder; $this->localApps = $finder->findApplications($projectRoot); } } @@ -240,12 +237,8 @@ private function getLocalApps() /** * Returns the local path to an app. - * - * @param App $app - * - * @return string|null */ - private function getLocalAppPath(App $app) + private function getLocalAppPath(App $app): ?string { foreach ($this->getLocalApps() as $path => $candidateApp) { if ($candidateApp->getName() === $app->getName()) { @@ -256,20 +249,15 @@ private function getLocalAppPath(App $app) return null; } - /** - * @param \Platformsh\Cli\Model\RemoteContainer\App $app - * - * @return string|null - */ - private function getSharedDir(App $app) + private function getSharedDir(App $app): ?string { - $projectRoot = $this->getProjectRoot(); + $projectRoot = $this->selector->getProjectRoot(); if (!$projectRoot) { return null; } $localApps = $this->getLocalApps(); - $dirname = $projectRoot . '/' . $this->config()->get('local.shared_dir'); + $dirname = $projectRoot . '/' . $this->config->getStr('local.shared_dir'); if (count($localApps) > 1 && is_dir($dirname)) { $dirname .= $app->getName(); } diff --git a/src/Command/Mount/MountListCommand.php b/src/Command/Mount/MountListCommand.php index 4feb14e10c..d69f68f574 100644 --- a/src/Command/Mount/MountListCommand.php +++ b/src/Command/Mount/MountListCommand.php @@ -1,81 +1,87 @@ 'Mount path', 'definition' => 'Definition']; + /** @var array */ + private array $tableHeader = ['path' => 'Mount path', 'definition' => 'Definition']; + public function __construct(private readonly Api $api, private readonly Config $config, private readonly Io $io, private readonly Mount $mount, private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table) + { + parent::__construct(); + } - /** - * {@inheritdoc} - */ - protected function configure() + protected function configure(): void { $this - ->setName('mount:list') - ->setAliases(['mounts']) - ->setDescription('Get a list of mounts') ->addOption('paths', null, InputOption::VALUE_NONE, 'Output the mount paths only (one per line)') ->addOption('refresh', null, InputOption::VALUE_NONE, 'Whether to refresh the cache'); Table::configureInput($this->getDefinition(), $this->tableHeader); - $this->addProjectOption(); - $this->addEnvironmentOption(); - $this->addRemoteContainerOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->selector->addRemoteContainerOptions($this->getDefinition()); + $this->addCompleter($this->selector); } - /** - * {@inheritdoc} - */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - /** @var \Platformsh\Cli\Service\Mount $mountService */ - $mountService = $this->getService('mount'); - if (($applicationEnv = getenv($this->config()->get('service.env_prefix') . 'APPLICATION')) - && !LocalHost::conflictsWithCommandLineOptions($input, $this->config()->get('service.env_prefix'))) { - $this->debug('Selected host: localhost'); - $config = json_decode(base64_decode($applicationEnv), true) ?: []; - $mounts = $mountService->mountsFromConfig(new AppConfig($config)); + $environment = null; + if (($applicationEnv = getenv($this->config->getStr('service.env_prefix') . 'APPLICATION')) + && !LocalHost::conflictsWithCommandLineOptions($input, $this->config->getStr('service.env_prefix'))) { + $this->io->debug('Selected host: localhost'); + $config = json_decode((string) base64_decode($applicationEnv), true) ?: []; + $mounts = $this->mount->mountsFromConfig(new AppConfig($config)); $appName = $config['name']; - $appType = strpos($appName, '--') !== false ? 'worker' : 'app'; + $appType = str_contains((string) $appName, '--') ? 'worker' : 'app'; if (empty($mounts)) { $this->stdErr->writeln(sprintf( 'No mounts found in config variable: %s', - $this->config()->get('service.env_prefix') . 'APPLICATION' + $this->config->getStr('service.env_prefix') . 'APPLICATION', )); return 0; } } else { - $this->chooseEnvFilter = $this->filterEnvsMaybeActive(); - $this->validateInput($input); - $environment = $this->getSelectedEnvironment(); - $container = $this->selectRemoteContainer($input); + $selection = $this->selector->getSelection($input, new SelectorConfig(chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive())); + $environment = $selection->getEnvironment(); + $container = $selection->getRemoteContainer(); if ($container instanceof BrokenEnv) { $this->stdErr->writeln(sprintf( 'Unable to find deployment information for the environment: %s', - $this->api()->getEnvironmentLabel($environment, 'error') + $this->api->getEnvironmentLabel($environment, 'error'), )); return 1; } - $mounts = $mountService->mountsFromConfig($container->getConfig()); + $mounts = $this->mount->mountsFromConfig($container->getConfig()); $appName = $container->getName(); $appType = $container instanceof Worker ? 'worker' : 'app'; if (empty($mounts)) { $this->stdErr->writeln(sprintf( 'No mounts found on environment %s, %s %s', - $this->api()->getEnvironmentLabel($environment), + $this->api->getEnvironmentLabel($environment), $appType, - $appName + $appName, )); return 0; @@ -89,24 +95,20 @@ protected function execute(InputInterface $input, OutputInterface $output) } $rows = []; - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); foreach ($mounts as $path => $definition) { - $rows[] = ['path' => $path, 'definition' => $formatter->format($definition)]; + $rows[] = ['path' => $path, 'definition' => $this->propertyFormatter->format($definition)]; } - - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - if ($this->hasSelectedEnvironment()) { - $this->stdErr->writeln(sprintf('Mounts on environment %s, %s %s:', - $this->api()->getEnvironmentLabel($this->getSelectedEnvironment()), + if ($environment !== null) { + $this->stdErr->writeln(sprintf( + 'Mounts on environment %s, %s %s:', + $this->api->getEnvironmentLabel($environment), $appType, - $appName + $appName, )); } else { $this->stdErr->writeln(sprintf('Mounts on %s %s:', $appType, $appName)); } - $table->render($rows, $this->tableHeader); + $this->table->render($rows, $this->tableHeader); return 0; } diff --git a/src/Command/Mount/MountSizeCommand.php b/src/Command/Mount/MountSizeCommand.php index 4d7cb3c9a9..e22732c74c 100644 --- a/src/Command/Mount/MountSizeCommand.php +++ b/src/Command/Mount/MountSizeCommand.php @@ -1,20 +1,31 @@ */ + private array $tableHeader = [ 'mounts' => 'Mount(s)', 'sizes' => 'Size(s)', 'max' => 'Disk', @@ -22,35 +33,35 @@ class MountSizeCommand extends CommandBase 'available' => 'Available', 'percent_used' => '% Used', ]; + public function __construct(private readonly Config $config, private readonly Io $io, private readonly Mount $mount, private readonly RemoteEnvVars $remoteEnvVars, private readonly Selector $selector, private readonly Table $table) + { + parent::__construct(); + } - /** - * {@inheritdoc} - */ - protected function configure() + protected function configure(): void { $this - ->setName('mount:size') - ->setDescription('Check the disk usage of mounts') ->addOption('bytes', 'B', InputOption::VALUE_NONE, 'Show sizes in bytes') ->addOption('refresh', null, InputOption::VALUE_NONE, 'Refresh the cache'); Table::configureInput($this->getDefinition(), $this->tableHeader); Ssh::configureInput($this->getDefinition()); - $this->addProjectOption(); - $this->addEnvironmentOption(); - $this->addRemoteContainerOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->selector->addRemoteContainerOptions($this->getDefinition()); + $this->addCompleter($this->selector); $help = <<mounts key in the application configuration. + Mounts are directories mounted into the application from a persistent, writable + filesystem. They are configured in the mounts key in the application configuration. -The filesystem's total size is determined by the disk key in the same file. -EOF; - if ($this->config()->getWithDefault('api.metrics', false)) { + The filesystem's total size is determined by the disk key in the same file. + EOF; + if ($this->config->getBool('api.metrics')) { $this->stability = self::STABILITY_DEPRECATED; $help .= "\n\n"; $help .= 'Deprecated:'; - $help .= sprintf("\nThis command is deprecated and will be removed in a future version.\nTo see disk metrics, run: %s disk", $this->config()->get('application.executable')); + $help .= sprintf("\nThis command is deprecated and will be removed in a future version.\nTo see disk metrics, run: %s disk", $this->config->getStr('application.executable')); } $this->setHelp($help); } @@ -58,19 +69,19 @@ protected function configure() /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $host = $this->selectHost($input, getenv($this->config()->get('service.env_prefix') . 'APPLICATION')); - /** @var \Platformsh\Cli\Service\Mount $mountService */ - $mountService = $this->getService('mount'); + $selection = $this->selector->getSelection($input, new SelectorConfig( + allowLocalHost: getenv($this->config->getStr('service.env_prefix') . 'APPLICATION') !== false, + )); + $host = $this->selector->getHostFromSelection($input, $selection); if ($host instanceof LocalHost) { - /** @var \Platformsh\Cli\Service\RemoteEnvVars $envVars */ - $envVars = $this->getService('remote_env_vars'); + $envVars = $this->remoteEnvVars; $config = (new AppConfig($envVars->getArrayEnvVar('APPLICATION', $host))); - $mounts = $mountService->mountsFromConfig($config); + $mounts = $this->mount->mountsFromConfig($config); } else { - $container = $this->selectRemoteContainer($input); - $mounts = $mountService->mountsFromConfig($container->getConfig()); + $container = $selection->getRemoteContainer(); + $mounts = $this->mount->mountsFromConfig($container->getConfig()); } if (empty($mounts)) { @@ -97,7 +108,7 @@ protected function execute(InputInterface $input, OutputInterface $output) // mounts. // 3. Run a 'du' command on each of the mounted paths, to find their // individual sizes. - $appDirVar = $this->config()->get('service.env_prefix') . 'APP_DIR'; + $appDirVar = $this->config->getStr('service.env_prefix') . 'APP_DIR'; $commands = []; $commands[] = 'echo "$' . $appDirVar . '"'; $commands[] = 'echo'; @@ -120,7 +131,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $result = $host->runCommand($command); // Separate the commands' output. - list($appDir, $dfOutput, $duOutput) = explode("\n\n", $result, 3); + [$appDir, $dfOutput, $duOutput] = explode("\n\n", (string) $result, 3); // Parse the output. $volumeInfo = $this->parseDf($dfOutput, $appDir, $mountPaths); @@ -139,11 +150,11 @@ protected function execute(InputInterface $input, OutputInterface $output) } if ($showInBytes) { $row['sizes'] = implode("\n", $mountUsage); - $row['max'] = $info['total']; - $row['used'] = $info['used']; - $row['available'] = $info['available']; + $row['max'] = (string) $info['total']; + $row['used'] = (string) $info['used']; + $row['available'] = (string) $info['available']; } else { - $row['sizes'] = implode("\n", array_map([Helper::class, 'formatMemory'], $mountUsage)); + $row['sizes'] = implode("\n", array_map(Helper::formatMemory(...), $mountUsage)); $row['max'] = Helper::formatMemory($info['total']); $row['used'] = Helper::formatMemory($info['used']); $row['available'] = Helper::formatMemory($info['available']); @@ -151,26 +162,23 @@ protected function execute(InputInterface $input, OutputInterface $output) $row['percent_used'] = round($info['percent_used'], 1) . '%'; $rows[] = $row; } + $this->table->render($rows, $this->tableHeader); - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - $table->render($rows, $this->tableHeader); - - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { if (count($volumeInfo) === 1 && count($mountPaths) > 1) { $this->stdErr->writeln(''); $this->stdErr->writeln('All the mounts share the same disk.'); } $this->stdErr->writeln(''); $this->stdErr->writeln( - 'To increase the available space, edit the disk key in the application configuration.' + 'To increase the available space, edit the disk key in the application configuration.', ); - if ($this->config()->getWithDefault('api.metrics', false) && $this->config()->isCommandEnabled('metrics:disk')) { + if ($this->config->getBool('api.metrics') && $this->config->isCommandEnabled('metrics:disk')) { $this->stdErr->writeln(''); $this->stdErr->writeln('Deprecated:'); $this->stdErr->writeln('This command is deprecated and will be removed in a future version.'); - $this->stdErr->writeln(sprintf('To see disk metrics, run: %s disk', $this->config()->get('application.executable'))); + $this->stdErr->writeln(sprintf('To see disk metrics, run: %s disk', $this->config->getStr('application.executable'))); } } @@ -188,7 +196,7 @@ protected function execute(InputInterface $input, OutputInterface $output) * * @return string */ - private function getDfColumn($line, $columnName) + private function getDfColumn(string $line, string $columnName): string { $columnPatterns = [ 'filesystem' => '/^(.+?)(\s+[0-9])/', @@ -212,11 +220,11 @@ private function getDfColumn($line, $columnName) * * @param string $dfOutput * @param string $appDir - * @param array $mountPaths + * @param string[] $mountPaths * - * @return array + * @return array */ - private function parseDf($dfOutput, $appDir, array $mountPaths) + private function parseDf(string $dfOutput, string $appDir, array $mountPaths): array { $results = []; foreach (explode("\n", $dfOutput) as $i => $line) { @@ -226,10 +234,10 @@ private function parseDf($dfOutput, $appDir, array $mountPaths) try { $path = $this->getDfColumn($line, 'path'); } catch (\RuntimeException $e) { - $this->debug($e->getMessage()); + $this->io->debug($e->getMessage()); continue; } - if (strpos($path, $appDir . '/') !== 0) { + if (!str_starts_with($path, $appDir . '/')) { continue; } $mountPath = ltrim(substr($path, strlen($appDir)), '/'); @@ -257,11 +265,11 @@ private function parseDf($dfOutput, $appDir, array $mountPaths) * Parse the 'du' output. * * @param string $duOutput - * @param array $mountPaths + * @param string[] $mountPaths * - * @return array A list of mount sizes (in bytes) keyed by mount path. + * @return array A list of mount sizes (in bytes) keyed by mount path. */ - private function parseDu($duOutput, array $mountPaths) + private function parseDu(string $duOutput, array $mountPaths): array { $mountSizes = []; $duOutputSplit = explode("\n", $duOutput, count($mountPaths)); @@ -269,7 +277,8 @@ private function parseDu($duOutput, array $mountPaths) if (!isset($duOutputSplit[$i])) { throw new \RuntimeException("Failed to find row $i of 'du' command output: \n" . $duOutput); } - list($mountSizes[$mountPath],) = explode("\t", $duOutputSplit[$i], 2); + $parts = explode("\t", $duOutputSplit[$i], 2); + $mountSizes[$mountPath] = (int) $parts[0]; } return $mountSizes; diff --git a/src/Command/Mount/MountUploadCommand.php b/src/Command/Mount/MountUploadCommand.php index da62dd2917..ddb8782935 100644 --- a/src/Command/Mount/MountUploadCommand.php +++ b/src/Command/Mount/MountUploadCommand.php @@ -1,49 +1,59 @@ setName('mount:upload') - ->setDescription('Upload files to a mount, using rsync') ->addOption('source', null, InputOption::VALUE_REQUIRED, 'A directory containing files to upload') ->addOption('mount', 'm', InputOption::VALUE_REQUIRED, 'The mount (as an app-relative path)') ->addOption('delete', null, InputOption::VALUE_NONE, 'Whether to delete extraneous files in the mount') - ->addOption('exclude', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_REQUIRED, 'File(s) to exclude from the upload (pattern)') - ->addOption('include', null, InputOption::VALUE_IS_ARRAY|InputOption::VALUE_REQUIRED, 'File(s) not to exclude (pattern)') + ->addOption('exclude', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'File(s) to exclude from the upload (pattern)') + ->addOption('include', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, 'File(s) not to exclude (pattern)') ->addOption('refresh', null, InputOption::VALUE_NONE, 'Whether to refresh the cache'); - $this->addProjectOption(); - $this->addEnvironmentOption(); - $this->addRemoteContainerOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->selector->addRemoteContainerOptions($this->getDefinition()); + $this->addCompleter($this->selector); Ssh::configureInput($this->getDefinition()); } /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); - - $container = $this->selectRemoteContainer($input); - /** @var \Platformsh\Cli\Service\Mount $mountService */ - $mountService = $this->getService('mount'); - $mounts = $mountService->mountsFromConfig($container->getConfig()); + $selection = $this->selector->getSelection($input, new SelectorConfig(chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive())); + $container = $selection->getRemoteContainer(); + $mounts = $this->mount->mountsFromConfig($container->getConfig()); $sshUrl = $container->getSshUrl($input->getOption('instance')); if (empty($mounts)) { @@ -52,13 +62,8 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - /** @var \Platformsh\Cli\Service\Filesystem $fs */ - $fs = $this->getService('fs'); - if ($input->getOption('mount')) { - $mountPath = $mountService->matchMountPath($input->getOption('mount'), $mounts); + $mountPath = $this->mount->matchMountPath($input->getOption('mount'), $mounts); } elseif ($input->isInteractive()) { $options = []; foreach ($mounts as $path => $definition) { @@ -69,9 +74,9 @@ protected function execute(InputInterface $input, OutputInterface $output) } } - $mountPath = $questionHelper->choose( + $mountPath = $this->questionHelper->choose( $options, - 'Enter a number to choose a mount to upload to:' + 'Enter a number to choose a mount to upload to:', ); } else { $this->stdErr->writeln('The --mount option must be specified (in non-interactive mode).'); @@ -83,16 +88,15 @@ protected function execute(InputInterface $input, OutputInterface $output) $defaultSource = null; if ($input->getOption('source')) { $source = $input->getOption('source'); - } elseif ($projectRoot = $this->getProjectRoot()) { - $sharedMounts = $mountService->getSharedFileMounts($mounts); + } elseif ($projectRoot = $this->selector->getProjectRoot()) { + $sharedMounts = $this->mount->getSharedFileMounts($mounts); if (isset($sharedMounts[$mountPath])) { - if (file_exists($projectRoot . '/' . $this->config()->get('local.shared_dir') . '/' . $sharedMounts[$mountPath])) { - $defaultSource = $projectRoot . '/' . $this->config()->get('local.shared_dir') . '/' . $sharedMounts[$mountPath]; + if (file_exists($projectRoot . '/' . $this->config->getStr('local.shared_dir') . '/' . $sharedMounts[$mountPath])) { + $defaultSource = $projectRoot . '/' . $this->config->getStr('local.shared_dir') . '/' . $sharedMounts[$mountPath]; } } - /** @var \Platformsh\Cli\Local\ApplicationFinder $finder */ - $finder = $this->getService('app_finder'); + $finder = $this->applicationFinder; $applications = $finder->findApplications($projectRoot); $appPath = $projectRoot; foreach ($applications as $path => $candidateApp) { @@ -109,11 +113,11 @@ protected function execute(InputInterface $input, OutputInterface $output) if (empty($source)) { $questionText = 'Source directory'; if ($defaultSource !== null) { - $formattedDefaultSource = $fs->formatPathForDisplay($defaultSource); + $formattedDefaultSource = $this->filesystem->formatPathForDisplay($defaultSource); $questionText .= ' [' . $formattedDefaultSource . ']'; } $questionText .= ': '; - $source = $questionHelper->ask($input, $this->stdErr, new Question($questionText, $defaultSource)); + $source = $this->questionHelper->ask($input, $this->stdErr, new Question($questionText, $defaultSource)); } if (empty($source)) { @@ -122,18 +126,15 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - $fs->validateDirectory($source); - - /** @var \Platformsh\Cli\Service\Rsync $rsync */ - $rsync = $this->getService('rsync'); + $this->filesystem->validateDirectory($source); $confirmText = sprintf( "\nUploading files from %s to the remote mount %s" . "\n\nAre you sure you want to continue?", - $fs->formatPathForDisplay($source), - $mountPath + $this->filesystem->formatPathForDisplay($source), + $mountPath, ); - if (!$questionHelper->confirm($confirmText)) { + if (!$this->questionHelper->confirm($confirmText)) { return 1; } @@ -146,8 +147,8 @@ protected function execute(InputInterface $input, OutputInterface $output) ]; if (OsUtil::isOsX()) { - if ($rsync->supportsConvertingFilenames() !== false) { - $this->debug('Converting filenames with special characters (utf-8-mac to utf-8)'); + if ($this->rsync->supportsConvertingFilenames() !== false) { + $this->io->debug('Converting filenames with special characters (utf-8-mac to utf-8)'); $rsyncOptions['convert-mac-filenames'] = true; } else { $this->stdErr->writeln(''); @@ -156,7 +157,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } $this->stdErr->writeln(''); - $rsync->syncUp($sshUrl, $source, $mountPath, $rsyncOptions); + $this->rsync->syncUp($sshUrl, $source, $mountPath, $rsyncOptions); return 0; } diff --git a/src/Command/MultiAwareInterface.php b/src/Command/MultiAwareInterface.php index fb0656d260..7c14e98e85 100644 --- a/src/Command/MultiAwareInterface.php +++ b/src/Command/MultiAwareInterface.php @@ -1,17 +1,15 @@ setName('multi') - ->setDescription('Execute a command on multiple projects') + $this ->addArgument('cmd', InputArgument::REQUIRED | InputArgument::IS_ARRAY, 'The command to execute') - ->addOption('projects', 'p', InputOption::VALUE_REQUIRED, 'A list of project IDs, separated by commas and/or whitespace') + ->addOption('projects', 'p', InputOption::VALUE_REQUIRED, 'A list of project IDs. ' . ArrayArgument::SPLIT_HELP) ->addOption('continue', null, InputOption::VALUE_NONE, 'Continue running commands even if an exception is encountered') ->addOption('sort', null, InputOption::VALUE_REQUIRED, 'A property by which to sort the list of project options', 'title') ->addOption('reverse', null, InputOption::VALUE_NONE, 'Reverse the order of project options'); $this->addExample( 'List variables on the "main" environment for multiple projects', - "-p l7ywemwizmmgb,o43m25zns6k2d,3nyujoslhydhx -- var -e main" + "-p l7ywemwizmmgb,o43m25zns6k2d,3nyujoslhydhx -- var -e main", ); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $commandArgs = ArrayArgument::getArgument($input,'cmd'); + $commandArgs = ArrayArgument::getArgument($input, 'cmd'); $commandName = reset($commandArgs); $commandLine = implode(' ', array_map('escapeshellarg', $commandArgs)); if (!$commandName) { @@ -45,18 +57,22 @@ protected function execute(InputInterface $input, OutputInterface $output) $application = new Application(); $application->setRunningViaMulti(); $application->setAutoExit(false); + $application->setIO($input, $output); $command = $application->find($commandName); + if ($command instanceof LazyCommand) { + $command = $command->getCommand(); + } if (!$command instanceof MultiAwareInterface || !$command->canBeRunMultipleTimes()) { $this->stdErr->writeln(sprintf( 'The command %s cannot be run via "%s multi".', $commandName, - $this->config()->get('application.executable') + $this->config->getStr('application.executable'), )); return 1; } elseif (!$command->getDefinition()->hasOption('project')) { $this->stdErr->writeln(sprintf( 'The command %s does not have a --project option.', - $commandName + $commandName, )); return 1; } @@ -72,11 +88,11 @@ protected function execute(InputInterface $input, OutputInterface $output) "Running command on %d %s: %s", count($projects), count($projects) === 1 ? 'project' : 'projects', - $commandLine + $commandLine, )); foreach ($projects as $project) { $this->stdErr->writeln(''); - $this->stdErr->writeln('# Project: ' . $this->api()->getProjectLabel($project, false)); + $this->stdErr->writeln('# Project: ' . $this->api->getProjectLabel($project, false)); try { $commandInput = new StringInput($commandLine . ' --project ' . escapeshellarg($project->id)); $returnCode = $application->run($commandInput, $output); @@ -87,7 +103,7 @@ protected function execute(InputInterface $input, OutputInterface $output) if (!$continue) { throw $e; } - $application->renderException($e, $this->stdErr); + $application->renderThrowable($e, $this->stdErr); $success = false; } } @@ -96,14 +112,13 @@ protected function execute(InputInterface $input, OutputInterface $output) } /** - * Show a checklist using the dialog utility. + * Shows a checklist using the dialog utility. * - * @param string $text - * @param array $options + * @param array $options An array of project labels keyed by ID. * - * @return array + * @return string[] A list of project IDs. */ - protected function showDialogChecklist(array $options, $text = 'Choose item(s)') + protected function showDialogChecklist(array $options, string $text = 'Choose item(s)'): array { $width = 80; $height = 20; @@ -113,25 +128,28 @@ protected function showDialogChecklist(array $options, $text = 'Choose item(s)') escapeshellarg($text), $height, $width, - $listHeight + $listHeight, ); foreach ($options as $tag => $option) { $command .= sprintf(' %s %s off', escapeshellarg($tag), escapeshellarg($option)); } $dialogRc = file_get_contents(CLI_ROOT . '/resources/console/dialogrc'); - $dialogRcFile = $this->config()->getWritableUserDir() . '/dialogrc'; + $dialogRcFile = $this->config->getWritableUserDir() . '/dialogrc'; if ($dialogRc !== false && (file_exists($dialogRcFile) || file_put_contents($dialogRcFile, $dialogRc))) { putenv('DIALOGRC=' . $dialogRcFile); } $pipes = [2 => null]; $process = proc_open($command, [ - 2 => array('pipe', 'w'), + 2 => ['pipe', 'w'], ], $pipes); + if (!$process) { + throw new \RuntimeException('Failed to start dialog command: ' . $process); + } // Wait for and read result. - $result = array_filter(explode("\n", trim(stream_get_contents($pipes[2])))); + $result = array_filter(explode("\n", trim((string) stream_get_contents($pipes[2])))); // Close handles. if (is_resource($pipes[2])) { @@ -152,9 +170,9 @@ protected function showDialogChecklist(array $options, $text = 'Choose item(s)') * * @return BasicProjectInfo[] */ - protected function getAllProjectsBasicInfo(InputInterface $input) + protected function getAllProjectsBasicInfo(InputInterface $input): array { - $projects = $this->api()->getMyProjects(); + $projects = $this->api->getMyProjects(); if ($input->getOption('sort')) { Sort::sortObjects($projects, $input->getOption('sort')); } @@ -174,27 +192,24 @@ protected function getAllProjectsBasicInfo(InputInterface $input) * * @param InputInterface $input * - * @return \Platformsh\Client\Model\Project[]|false + * @return Project[]|false * An array of projects, or false on error. */ - protected function getSelectedProjects(InputInterface $input) + protected function getSelectedProjects(InputInterface $input): false|array { - $projectList = $input->getOption('projects'); - - /** @var \Platformsh\Cli\Service\Identifier $identifier */ - $identifier = $this->getService('identifier'); + $projectList = ArrayArgument::getOption($input, 'projects'); if (!empty($projectList)) { $missing = []; $selected = []; - foreach ($this->splitProjectList($projectList) as $projectId) { + foreach ($projectList as $projectId) { try { - $result = $identifier->identify($projectId); - } catch (InvalidArgumentException $e) { + $result = $this->identifier->identify($projectId); + } catch (InvalidArgumentException) { $missing[] = $projectId; continue; } - $project = $this->api()->getProject($result['projectId'], $result['host']); + $project = $this->api->getProject($result['projectId'], $result['host']); if ($project !== false) { $selected[$project->id] = $project; } else { @@ -213,10 +228,7 @@ protected function getSelectedProjects(InputInterface $input) $this->stdErr->writeln('In non-interactive mode, the --projects option must be specified.'); return false; } - - /** @var \Platformsh\Cli\Service\Shell $shell */ - $shell = $this->getService('shell'); - if (!$shell->commandExists('dialog')) { + if (!$this->shell->commandExists('dialog')) { $this->stdErr->writeln('The "dialog" utility is required for interactive use.'); $this->stdErr->writeln('You can specify projects via the --projects option.'); return false; @@ -236,47 +248,12 @@ protected function getSelectedProjects(InputInterface $input) $this->stdErr->writeln('Selected project(s): ' . implode(',', $selected)); $this->stdErr->writeln(''); - return array_map(function ($id) { return $this->api()->getProject($id); }, $selected); - } - - /** - * Split a list of project IDs. - * - * @param string $list - * - * @return string[] - */ - private function splitProjectList($list) - { - return array_filter(array_unique(preg_split('/[,\s]+/', $list) ?: [])); - } - - /** - * {@inheritdoc} - */ - public function completeOptionValues($optionName, CompletionContext $context) - { - return []; - } - - /** - * {@inheritdoc} - */ - public function completeArgumentValues($argumentName, CompletionContext $context) - { - if ($argumentName === 'cmd') { - $commandNames = []; - foreach ($this->getApplication()->all() as $command) { - if ($command instanceof MultiAwareInterface - && $command->canBeRunMultipleTimes() - && $command->getDefinition()->hasOption('project')) { - $commandNames[] = $command->getName(); - } + return array_map(function ($id) { + $project = $this->api->getProject($id); + if (!$project) { + throw new \RuntimeException('Failed to fetch project: ' . $id); } - - return $commandNames; - } - - return []; + return $project; + }, $selected); } } diff --git a/src/Command/Organization/Billing/OrganizationAddressCommand.php b/src/Command/Organization/Billing/OrganizationAddressCommand.php index 5d9f99d622..e2278a7eac 100644 --- a/src/Command/Organization/Billing/OrganizationAddressCommand.php +++ b/src/Command/Organization/Billing/OrganizationAddressCommand.php @@ -1,6 +1,11 @@ setName('organization:billing:address') - ->setDescription("View or change an organization's billing address") - ->addOrganizationOptions(true) - ->addArgument('property', InputArgument::OPTIONAL, 'The name of a property to view or change') + $this->selector->addOrganizationOptions($this->getDefinition(), true); + $this->addCompleter($this->selector); + $this->addArgument('property', InputArgument::OPTIONAL, 'The name of a property to view or change') ->addArgument('value', InputArgument::OPTIONAL, 'A new value for the property') - ->addArgument('properties', InputArgument::IS_ARRAY|InputArgument::OPTIONAL, 'Additional property/value pairs'); + ->addArgument('properties', InputArgument::IS_ARRAY | InputArgument::OPTIONAL, 'Additional property/value pairs'); PropertyFormatter::configureInput($this->getDefinition()); Table::configureInput($this->getDefinition()); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $property = $input->getArgument('property'); $updates = $this->parseUpdates($input); // The 'orders' link depends on the billing permission. - $org = $this->validateOrganizationInput($input, 'orders'); + $org = $this->selector->selectOrganization($input, 'orders'); $address = $org->getAddress(); - /** @var PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - $result = 0; if ($property !== null) { if (empty($updates)) { - $formatter->displayData($output, $address->getProperties(), $property); + $this->propertyFormatter->displayData($output, $address->getProperties(), $property); return $result; } $result = $this->setProperties($updates, $address); @@ -55,33 +62,31 @@ protected function execute(InputInterface $input, OutputInterface $output) return $result; } - protected function display(Address $address, Organization $org, InputInterface $input) + protected function display(Address $address, Organization $org, InputInterface $input): void { - /** @var Table $table */ - $table = $this->getService('table'); - $headings = []; $values = []; - /** @var PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); foreach ($address->getProperties() as $key => $value) { $headings[] = new AdaptiveTableCell($key, ['wrap' => false]); - $values[] = $formatter->format($value, $key); + $values[] = $this->propertyFormatter->format($value, $key); } - if (!$table->formatIsMachineReadable()) { - $this->stdErr->writeln(\sprintf('Billing address for the organization %s:', $this->api()->getOrganizationLabel($org))); + if (!$this->table->formatIsMachineReadable()) { + $this->stdErr->writeln(\sprintf('Billing address for the organization %s:', $this->api->getOrganizationLabel($org))); } - $table->renderSimple($values, $headings); + $this->table->renderSimple($values, $headings); - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->stdErr->writeln(\sprintf('To view the billing profile, run: %s', $this->otherCommandExample($input, 'org:billing:profile'))); $this->stdErr->writeln(\sprintf('To view organization details, run: %s', $this->otherCommandExample($input, 'org:info'))); } } - protected function parseUpdates(InputInterface $input) + /** + * @return array + */ + protected function parseUpdates(InputInterface $input): array { $property = $input->getArgument('property'); $value = $input->getArgument('value'); @@ -117,12 +122,12 @@ protected function parseUpdates(InputInterface $input) } /** - * @param array $updates + * @param array $updates * @param Address $address * * @return int */ - protected function setProperties(array $updates, Address $address) + protected function setProperties(array $updates, Address $address): int { $currentValues = \array_intersect_key($address->getProperties(), $updates); if ($currentValues == $updates) { @@ -137,7 +142,7 @@ protected function setProperties(array $updates, Address $address) return 0; } catch (BadResponseException $e) { // Translate validation error messages. - if (($response = $e->getResponse()) && $response->getStatusCode() === 400 && ($body = $response->getBody())) { + if ($e->getResponse()->getStatusCode() === 400 && ($body = $e->getResponse()->getBody())) { $detail = \json_decode((string) $body, true); if (\is_array($detail) && isset($detail['title']) && \is_string($detail['title'])) { $this->stdErr->writeln($detail['title']); @@ -163,7 +168,7 @@ protected function setProperties(array $updates, Address $address) * * @return string|false */ - private function getType($property) + private function getType(string $property): string|false { $writableProperties = [ 'country' => 'string', @@ -178,16 +183,10 @@ private function getType($property) 'postal_code' => 'string', ]; - return isset($writableProperties[$property]) ? $writableProperties[$property] : false; + return $writableProperties[$property] ?? false; } - /** - * @param string $property - * @param string &$value - * - * @return bool - */ - private function validateValue($property, &$value) + private function validateValue(string $property, string &$value): bool { $type = $this->getType($property); if (!$type) { diff --git a/src/Command/Organization/Billing/OrganizationProfileCommand.php b/src/Command/Organization/Billing/OrganizationProfileCommand.php index 6257e044bc..452619da3f 100644 --- a/src/Command/Organization/Billing/OrganizationProfileCommand.php +++ b/src/Command/Organization/Billing/OrganizationProfileCommand.php @@ -1,54 +1,57 @@ setName('organization:billing:profile') - ->setDescription("View or change an organization's billing profile") - ->addOrganizationOptions(true) - ->addArgument('property', InputArgument::OPTIONAL, 'The name of a property to view or change') + $this->selector->addOrganizationOptions($this->getDefinition(), true); + $this->addCompleter($this->selector); + $this->addArgument('property', InputArgument::OPTIONAL, 'The name of a property to view or change') ->addArgument('value', InputArgument::OPTIONAL, 'A new value for the property'); PropertyFormatter::configureInput($this->getDefinition()); Table::configureInput($this->getDefinition()); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $org = $this->validateOrganizationInput($input, 'orders'); + $org = $this->selector->selectOrganization($input, 'orders'); $profile = $org->getProfile(); - /** @var PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - $property = $input->getArgument('property'); if ($property === null) { $headings = []; $values = []; - /** @var PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); foreach ($profile->getProperties() as $key => $value) { $headings[] = new AdaptiveTableCell($key, ['wrap' => false]); - $values[] = $formatter->format($value, $key); + $values[] = $this->propertyFormatter->format($value, $key); } - /** @var Table $table */ - $table = $this->getService('table'); + $table = $this->table; if (!$table->formatIsMachineReadable()) { - $this->stdErr->writeln(\sprintf('Billing profile for the organization %s:', $this->api()->getOrganizationLabel($org))); + $this->stdErr->writeln(\sprintf('Billing profile for the organization %s:', $this->api->getOrganizationLabel($org))); } $table->renderSimple($values, $headings); @@ -62,34 +65,25 @@ protected function execute(InputInterface $input, OutputInterface $output) $value = $input->getArgument('value'); if ($value === null) { - $formatter->displayData($output, $profile->getProperties(), $property); + $this->propertyFormatter->displayData($output, $profile->getProperties(), $property); return 0; } return $this->setProperty($property, $value, $profile); } - /** - * @param string $property - * @param string $value - * @param Profile $profile - * - * @return int - */ - protected function setProperty($property, $value, Profile $profile) + protected function setProperty(string $property, string $value, Profile $profile): int { if (!$this->validateValue($property, $value)) { return 1; } - /** @var PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); $currentValue = $profile->getProperty($property, false); if ($currentValue === $value) { $this->stdErr->writeln(sprintf( 'Property %s already set as: %s', $property, - $formatter->format($profile->getProperty($property, false), $property) + $this->propertyFormatter->format($profile->getProperty($property, false), $property), )); return 0; @@ -98,7 +92,7 @@ protected function setProperty($property, $value, Profile $profile) $profile->update([$property => $value]); } catch (BadResponseException $e) { // Translate validation error messages. - if (($response = $e->getResponse()) && $response->getStatusCode() === 400 && ($body = $response->getBody())) { + if ($e->getResponse()->getStatusCode() === 400 && ($body = $e->getResponse()->getBody())) { $detail = \json_decode((string) $body, true); if (\is_array($detail) && !empty($detail['detail'][$property])) { $this->stdErr->writeln("Invalid value for $property: " . $detail['detail'][$property]); @@ -114,20 +108,16 @@ protected function setProperty($property, $value, Profile $profile) $this->stdErr->writeln(sprintf( 'Property %s set to: %s', $property, - $formatter->format($profile->$property, $property) + $this->propertyFormatter->format($profile->$property, $property), )); return 0; } /** - * Get the type of a writable property. - * - * @param string $property - * - * @return string|false + * Gets the type of a writable property. */ - private function getType($property) + private function getType(string $property): string|false { $writableProperties = [ 'company_name' => 'string', @@ -138,16 +128,10 @@ private function getType($property) 'project_options_url' => 'string', ]; - return isset($writableProperties[$property]) ? $writableProperties[$property] : false; + return $writableProperties[$property] ?? false; } - /** - * @param string $property - * @param string &$value - * - * @return bool - */ - private function validateValue($property, &$value) + private function validateValue(string $property, string &$value): bool { $type = $this->getType($property); if (!$type) { diff --git a/src/Command/Organization/OrganizationCommandBase.php b/src/Command/Organization/OrganizationCommandBase.php index 73e6106597..e07317de82 100644 --- a/src/Command/Organization/OrganizationCommandBase.php +++ b/src/Command/Organization/OrganizationCommandBase.php @@ -1,7 +1,13 @@ config()->getWithDefault('api.organizations', false)) { + $this->api = $api; + $this->config = $config; + $this->questionHelper = $questionHelper; + } + + public function isEnabled(): bool + { + if (!$this->config->getBool('api.organizations')) { return false; } return parent::isEnabled(); } - protected function memberLabel(Member $member) + protected function memberLabel(Member $member): string { if ($info = $member->getUserInfo()) { return $info->email; @@ -41,10 +59,10 @@ protected function memberLabel(Member $member) * * @return string */ - protected function otherCommandExample(InputInterface $input, $commandName, $otherArgs = '') + protected function otherCommandExample(InputInterface $input, string $commandName, string $otherArgs = ''): string { $args = [ - $this->config()->get('application.executable'), + $this->config->getStr('application.executable'), $commandName, ]; if ($input->hasOption('org') && $input->getOption('org')) { @@ -56,61 +74,15 @@ protected function otherCommandExample(InputInterface $input, $commandName, $oth return \implode(' ', $args); } - /** - * Returns a list of countries, keyed by 2-letter country code. - * - * @return array - */ - protected function countryList() - { - static $data; - if (isset($data)) { - return $data; - } - $filename = CLI_ROOT . '/resources/cldr/countries.json'; - $data = \json_decode((string) \file_get_contents($filename), true); - if (!$data) { - throw new \RuntimeException('Failed to read CLDR file: ' . $filename); - } - return $data; - } - - /** - * Normalizes a given country, transforming it into a country code, if possible. - * - * @param string $country - * - * @return string - */ - protected function normalizeCountryCode($country) - { - $countryList = $this->countryList(); - if (isset($countryList[$country])) { - return $country; - } - // Exact match. - if (($code = \array_search($country, $countryList)) !== false) { - return $code; - } - // Case-insensitive match. - $lower = \strtolower($country); - foreach ($countryList as $code => $name) { - if ($lower === \strtolower($name) || $lower === \strtolower($code)) { - return $code; - } - } - return $country; - } - /** * Presents an interactive choice to pick a member in the organization. * * @param Organization $organization * @return Member */ - protected function chooseMember(Organization $organization) + protected function chooseMember(Organization $organization): Member { - $httpClient = $this->api()->getHttpClient(); + $httpClient = $this->api->getHttpClient(); $options = ['query' => ['page[size]' => 100]]; $url = $organization->getUri() . '/members'; /** @var Member[] $members */ @@ -134,20 +106,18 @@ protected function chooseMember(Organization $organization) continue; } $emailAddresses[$member->user_id] = $member->getUserInfo()->email; - $choices[$member->user_id] = $this->api()->getMemberLabel($member); + $choices[$member->user_id] = $this->api->getMemberLabel($member); $byId[$member->user_id] = $member; } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); if (count($choices) < 25) { $default = null; if (isset($choices[$organization->owner_id])) { $choices[$organization->owner_id] .= ' (owner - default)'; $default = $organization->owner_id; } - $userId = $questionHelper->choose($choices, 'Enter a number to choose a user:', $default); + $userId = $this->questionHelper->choose($choices, 'Enter a number to choose a user:', $default); } else { - $userId = $questionHelper->askInput('Enter an email address to choose a user', null, array_values($emailAddresses), function ($email) use ($emailAddresses) { + $userId = $this->questionHelper->askInput('Enter an email address to choose a user', null, array_values($emailAddresses), function (string $email) use ($emailAddresses): string { if (($key = array_search($email, $emailAddresses)) === false) { throw new InvalidArgumentException('User not found: ' . $email); } diff --git a/src/Command/Organization/OrganizationCreateCommand.php b/src/Command/Organization/OrganizationCreateCommand.php index 7749d04344..9bb880c75d 100644 --- a/src/Command/Organization/OrganizationCreateCommand.php +++ b/src/Command/Organization/OrganizationCreateCommand.php @@ -1,91 +1,93 @@ setName('organization:create') - ->setDescription('Create a new organization'); $this->getForm()->configureInputDefinition($this->getDefinition()); - $serviceName = $this->config()->get('service.name'); + $serviceName = $this->config->getStr('service.name'); $help = <<setHelp($help); } - private function getForm() + private function getForm(): Form { - $countryList = $this->countryList(); + $countryList = $this->countryService->listCountries(); return Form::fromArray([ 'label' => new Field('Label', [ 'description' => 'The full name of the organization, e.g. "ACME Inc."', ]), 'name' => new Field('Name', [ 'description' => 'The organization machine name, used for URL paths and similar purposes.', - 'defaultCallback' => function ($values) { - return isset($values['label']) ? (new Slugify())->slugify($values['label']) : null; - }, + 'defaultCallback' => fn($values) => isset($values['label']) ? (new Slugify())->slugify($values['label']) : null, ]), 'country' => new OptionsField('Country', [ 'description' => 'The organization country. Used as the default for the billing address.', 'options' => $countryList, 'asChoice' => false, - 'defaultCallback' => function () { - return $this->api()->getUser()->country ?: null; - }, - 'normalizer' => function ($value) { return $this->normalizeCountryCode($value); }, - 'validator' => function ($countryCode) use ($countryList) { - return isset($countryList[$countryCode]) ? true : "Invalid country: $countryCode"; - }, + 'defaultCallback' => fn() => $this->api->getUser()->country ?: null, + 'normalizer' => $this->countryService->countryToCode(...), + 'validator' => fn($countryCode) => isset($countryList[$countryCode]) ? true : "Invalid country: $countryCode", ]), ]); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { // Ensure login before presenting the form. - $client = $this->api()->getClient(); - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); + $client = $this->api->getClient(); $form = $this->getForm(); if (($name = $input->getOption('name')) && $input->getOption('label') === null) { - $form->getField('label')->set('default', \ucfirst($name)); + $form->getField('label')->set('default', \ucfirst((string) $name)); } - $values = $form->resolveOptions($input, $output, $questionHelper); + $values = $form->resolveOptions($input, $output, $this->questionHelper); - if (!$questionHelper->confirm(\sprintf('Are you sure you want to create a new organization %s?', $values['name']), false)) { + if (!$this->questionHelper->confirm(\sprintf('Are you sure you want to create a new organization %s?', $values['name']), false)) { return 1; } try { $organization = $client->createOrganization($values['name'], $values['label'], $values['country']); } catch (BadResponseException $e) { - if ($e->getResponse() && $e->getResponse()->getStatusCode() === 409) { + if ($e->getResponse()->getStatusCode() === 409) { $this->stdErr->writeln(\sprintf('An organization already exists with the same name: %s', $values['name'])); return 1; } throw $e; } - $this->stdErr->writeln(sprintf('Created organization %s', $this->api()->getOrganizationLabel($organization))); + $this->stdErr->writeln(sprintf('Created organization %s', $this->api->getOrganizationLabel($organization))); - $this->runOtherCommand('organization:info', ['--org' => $organization->name], $this->stdErr); + $this->subCommandRunner->run('organization:info', ['--org' => $organization->name], $this->stdErr); return 0; } diff --git a/src/Command/Organization/OrganizationCurlCommand.php b/src/Command/Organization/OrganizationCurlCommand.php index c02527a5f3..e31a187538 100644 --- a/src/Command/Organization/OrganizationCurlCommand.php +++ b/src/Command/Organization/OrganizationCurlCommand.php @@ -1,32 +1,39 @@ setName('organization:curl') - ->setDescription("Run an authenticated cURL request on an organization's API") - ->addOrganizationOptions(true); + $this->selector->addOrganizationOptions($this->getDefinition(), true); + $this->addCompleter($this->selector); CurlCli::configureInput($this->getDefinition()); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $organization = $this->validateOrganizationInput($input); - - $apiUrl = Url::fromString($this->config()->getApiUrl()); - $absoluteUrl = $apiUrl->combine($organization->getUri())->__toString(); + $organization = $this->selector->selectOrganization($input); - /** @var CurlCli $curl */ - $curl = $this->getService('curl_cli'); - return $curl->run($absoluteUrl, $input, $output); + $apiUri = new Uri($this->config->getApiUrl()); + $absoluteUrl = $apiUri->withPath($organization->getUri()); + return $this->curlCli->run((string) $absoluteUrl, $input, $output); } } diff --git a/src/Command/Organization/OrganizationDeleteCommand.php b/src/Command/Organization/OrganizationDeleteCommand.php index 331ad30feb..605a31080c 100644 --- a/src/Command/Organization/OrganizationDeleteCommand.php +++ b/src/Command/Organization/OrganizationDeleteCommand.php @@ -1,26 +1,36 @@ setName('organization:delete') - ->setDescription('Delete an organization') - ->addOrganizationOptions(true); + parent::__construct(); + } + protected function configure(): void + { + $this->selector->addOrganizationOptions($this->getDefinition(), true); + $this->addCompleter($this->selector); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $organization = $this->validateOrganizationInput($input); + $organization = $this->selector->selectOrganization($input); $subscriptions = $organization->getSubscriptions(); if (!empty($subscriptions)) { - $this->stdErr->writeln(\sprintf('The organization %s still owns project(s), so it cannot be deleted.', $this->api()->getOrganizationLabel($organization, 'comment'))); + $this->stdErr->writeln(\sprintf('The organization %s still owns project(s), so it cannot be deleted.', $this->api->getOrganizationLabel($organization, 'comment'))); $this->stdErr->writeln(''); $this->stdErr->writeln('You would need to delete the projects or transfer them to another organization first.'); $this->stdErr->writeln(''); @@ -28,17 +38,14 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - - if (!$questionHelper->confirm(\sprintf('Are you sure you want to delete the organization %s?', $this->api()->getOrganizationLabel($organization)), false)) { + if (!$this->questionHelper->confirm(\sprintf('Are you sure you want to delete the organization %s?', $this->api->getOrganizationLabel($organization)), false)) { return 1; } $organization->delete(); $this->stdErr->writeln(''); - $this->stdErr->writeln('The organization ' . $this->api()->getOrganizationLabel($organization) . ' was deleted.'); + $this->stdErr->writeln('The organization ' . $this->api->getOrganizationLabel($organization) . ' was deleted.'); return 0; } diff --git a/src/Command/Organization/OrganizationInfoCommand.php b/src/Command/Organization/OrganizationInfoCommand.php index b179bf61c4..7bedeb43fd 100644 --- a/src/Command/Organization/OrganizationInfoCommand.php +++ b/src/Command/Organization/OrganizationInfoCommand.php @@ -1,25 +1,35 @@ setName('organization:info') - ->setDescription('View or change organization details') - ->addOrganizationOptions(true) - ->addArgument('property', InputArgument::OPTIONAL, 'The name of a property to view or change') + parent::__construct(); + } + protected function configure(): void + { + $this->selector->addOrganizationOptions($this->getDefinition(), true); + $this->addCompleter($this->selector); + $this->addArgument('property', InputArgument::OPTIONAL, 'The name of a property to view or change') ->addArgument('value', InputArgument::OPTIONAL, 'A new value for the property') ->addOption('refresh', null, InputOption::VALUE_NONE, 'Refresh the cache'); PropertyFormatter::configureInput($this->getDefinition()); @@ -29,15 +39,12 @@ protected function configure() ->addExample('Change the organization label', '--org acme label "ACME Inc."'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $property = $input->getArgument('property'); $value = $input->getArgument('value'); $skipCache = $value !== null || $input->getOption('refresh'); - $organization = $this->validateOrganizationInput($input, '', '', $skipCache); - - /** @var PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); + $organization = $this->selector->selectOrganization($input, '', '', $skipCache); if ($property === null) { $this->listProperties($organization); @@ -45,14 +52,17 @@ protected function execute(InputInterface $input, OutputInterface $output) } if ($value === null) { - $formatter->displayData($output, $this->getProperties($organization), $property); + $this->propertyFormatter->displayData($output, $this->getProperties($organization), $property); return 0; } return $this->setProperty($property, $value, $organization); } - private function getProperties(Organization $organization) + /** + * @return array + */ + private function getProperties(Organization $organization): array { $data = $organization->getProperties(); @@ -65,42 +75,29 @@ private function getProperties(Organization $organization) return $data; } - private function listProperties(Organization $organization) + private function listProperties(Organization $organization): void { $headings = []; $values = []; - /** @var PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); foreach ($this->getProperties($organization) as $key => $value) { $headings[] = new AdaptiveTableCell($key, ['wrap' => false]); - $values[] = $formatter->format($value, $key); + $values[] = $this->propertyFormatter->format($value, $key); } - /** @var Table $table */ - $table = $this->getService('table'); - $table->renderSimple($values, $headings); + $this->table->renderSimple($values, $headings); } - /** - * @param string $property - * @param string $value - * @param Organization $organization - * - * @return int - */ - protected function setProperty($property, $value, Organization $organization) + protected function setProperty(string $property, string $value, Organization $organization): int { if (!$this->validateValue($property, $value)) { return 1; } - /** @var PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); $currentValue = $organization->getProperty($property, false); if ($currentValue === $value) { $this->stdErr->writeln(sprintf( 'Property %s already set as: %s', $property, - $formatter->format($organization->getProperty($property, false), $property) + $this->propertyFormatter->format($organization->getProperty($property, false), $property), )); return 0; @@ -109,7 +106,7 @@ protected function setProperty($property, $value, Organization $organization) $organization->update([$property => $value]); } catch (BadResponseException $e) { // Translate validation error messages. - if (($response = $e->getResponse()) && $response->getStatusCode() === 400 && ($body = $response->getBody())) { + if ($e->getResponse()->getStatusCode() === 400 && ($body = $e->getResponse()->getBody())) { $detail = \json_decode((string) $body, true); if (\is_array($detail) && !empty($detail['detail'][$property])) { $this->stdErr->writeln("Invalid value for $property: " . $detail['detail'][$property]); @@ -122,24 +119,20 @@ protected function setProperty($property, $value, Organization $organization) } throw $e; } - $this->api()->clearOrganizationCache($organization); + $this->api->clearOrganizationCache($organization); $this->stdErr->writeln(sprintf( 'Property %s set to: %s', $property, - $formatter->format($organization->$property, $property) + $this->propertyFormatter->format($organization->$property, $property), )); return 0; } /** - * Get the type of a writable property. - * - * @param string $property - * - * @return string|false + * Gets the type of a writable property. */ - private function getType($property) + private function getType(string $property): string|false { $writableProperties = [ 'name' => 'string', @@ -147,16 +140,10 @@ private function getType($property) 'country' => 'string', ]; - return isset($writableProperties[$property]) ? $writableProperties[$property] : false; + return $writableProperties[$property] ?? false; } - /** - * @param string $property - * @param string &$value - * - * @return bool - */ - private function validateValue($property, &$value) + private function validateValue(string $property, string &$value): bool { $type = $this->getType($property); if (!$type) { @@ -165,8 +152,8 @@ private function validateValue($property, &$value) return false; } if ($property === 'country') { - $value = $this->normalizeCountryCode($value); - if (!isset($this->countryList()[$value])) { + $value = $this->countryService->countryToCode($value); + if (!isset($this->countryService->listCountries()[$value])) { $this->stdErr->writeln("Unrecognized country name or code: $value"); return false; } diff --git a/src/Command/Organization/OrganizationListCommand.php b/src/Command/Organization/OrganizationListCommand.php index 28f2fbe70f..92f6b4f499 100644 --- a/src/Command/Organization/OrganizationListCommand.php +++ b/src/Command/Organization/OrganizationListCommand.php @@ -1,14 +1,23 @@ */ + private array $tableHeader = [ 'id' => 'ID', 'name' => 'Name', 'label' => 'Label', @@ -18,16 +27,16 @@ class OrganizationListCommand extends OrganizationCommandBase 'owner_email' => 'Owner email', 'owner_username' => 'Owner username', ]; - private $defaultColumns = ['name', 'label', 'owner_email']; + /** @var string[] */ + private array $defaultColumns = ['name', 'label', 'owner_email']; + public function __construct(private readonly Api $api, private readonly Config $config, private readonly Selector $selector, private readonly Table $table) + { + parent::__construct(); + } - /** - * {@inheritdoc} - */ - protected function configure() + protected function configure(): void { - $this->setName('organization:list') - ->setAliases(['orgs', 'organizations']) - ->setDescription('List organizations') + $this ->addOption('my', null, InputOption::VALUE_NONE, 'List only the organizations you own') ->addOption('sort', null, InputOption::VALUE_REQUIRED, 'An organization property to sort by') ->addOption('reverse', null, InputOption::VALUE_NONE, 'Sort in reverse order'); @@ -37,10 +46,10 @@ protected function configure() /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $client = $this->api()->getClient(); - $userId = $this->api()->getMyUserId(); + $client = $this->api->getClient(); + $userId = $this->api->getMyUserId(); if ($input->getOption('my')) { $organizations = $client->listOrganizationsByOwner($userId); @@ -49,16 +58,16 @@ protected function execute(InputInterface $input, OutputInterface $output) } if ($sortBy = $input->getOption('sort')) { - $this->api()->sortResources($organizations, $sortBy); + $this->api->sortResources($organizations, $sortBy); } if ($input->getOption('reverse')) { $organizations = array_reverse($organizations, true); } - $executable = $this->config()->get('application.executable'); + $executable = $this->config->getStr('application.executable'); if (empty($organizations)) { $this->stdErr->writeln('No organizations found.'); - if ($this->config()->isCommandEnabled('organization:create')) { + if ($this->config->isCommandEnabled('organization:create')) { $this->stdErr->writeln(''); $this->stdErr->writeln(\sprintf('To create a new organization, run: %s org:create', $executable)); } @@ -66,16 +75,13 @@ protected function execute(InputInterface $input, OutputInterface $output) } $currentProjectOrg = null; - $currentProject = $this->getCurrentProject(true); + $currentProject = $this->selector->getCurrentProject(true); if ($currentProject && $currentProject->hasProperty('organization')) { $currentProjectOrg = $currentProject->getProperty('organization'); } - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - $rows = []; - $machineReadable = $table->formatIsMachineReadable(); + $machineReadable = $this->table->formatIsMachineReadable(); $markedCurrent = false; foreach ($organizations as $org) { $row = $org->getProperties(); @@ -97,13 +103,13 @@ protected function execute(InputInterface $input, OutputInterface $output) } } - $table->render($rows, $this->tableHeader, $this->defaultColumns); + $this->table->render($rows, $this->tableHeader, $this->defaultColumns); if ($markedCurrent) { $this->stdErr->writeln("* - Indicates the current project's organization"); } - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->stdErr->writeln(''); $this->stdErr->writeln(\sprintf('To view or modify organization details, run: %s org:info [-o organization]', $executable)); $this->stdErr->writeln(\sprintf('To see all organization commands run: %s list organization', $executable)); diff --git a/src/Command/Organization/OrganizationSubscriptionListCommand.php b/src/Command/Organization/OrganizationSubscriptionListCommand.php index 3f47d51ff7..9fb0995b3e 100644 --- a/src/Command/Organization/OrganizationSubscriptionListCommand.php +++ b/src/Command/Organization/OrganizationSubscriptionListCommand.php @@ -1,16 +1,25 @@ */ + private array $tableHeader = [ 'id' => 'Subscription ID', 'project_id' => 'Project ID', 'project_title' => 'Title', @@ -18,27 +27,29 @@ class OrganizationSubscriptionListCommand extends OrganizationCommandBase 'created_at' => 'Created at', 'updated_at' => 'Updated at', ]; - private $defaultColumns = ['id', 'project_id', 'project_title', 'project_region']; - /** - * {@inheritdoc} - */ - protected function configure() + /** @var string[] */ + private array $defaultColumns = ['id', 'project_id', 'project_title', 'project_region']; + + public function __construct(private readonly Api $api, private readonly Config $config, private readonly Selector $selector, private readonly Table $table) { - $this->setName('organization:subscription:list') - ->setAliases(['org:subs']) - ->setHiddenAliases(['organization:subscriptions']) - ->setDescription('List subscriptions within an organization') + parent::__construct(); + } + + protected function configure(): void + { + $this->setHiddenAliases(['organization:subscriptions']) ->addOption('page', null, InputOption::VALUE_REQUIRED, 'Page number. This enables pagination, despite configuration or --count.') - ->addOption('count', 'c', InputOption::VALUE_REQUIRED, 'The number of items to display per page. Use 0 to disable pagination. Ignored if --page is specified.') - ->addOrganizationOptions(true); + ->addOption('count', 'c', InputOption::VALUE_REQUIRED, 'The number of items to display per page. Use 0 to disable pagination. Ignored if --page is specified.'); + $this->selector->addOrganizationOptions($this->getDefinition(), true); + $this->addCompleter($this->selector); Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); } /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $options = []; $options['query']['filter']['status']['value'][] = 'active'; @@ -46,7 +57,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $options['query']['filter']['status']['operator'] = 'IN'; $count = $input->getOption('count'); - $itemsPerPage = (int) $this->config()->getWithDefault('pagination.count', 20); + $itemsPerPage = $this->config->getInt('pagination.count'); if ($count !== null && $count !== '0') { if (!\is_numeric($count) || $count > 50) { $this->stdErr->writeln('The --count must be a number between 1 and 50, or 0 to disable pagination.'); @@ -56,7 +67,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } $options['query']['range'] = $itemsPerPage; - $fetchAllPages = !$this->config()->getWithDefault('pagination.enabled', true); + $fetchAllPages = !$this->config->getBool('pagination.enabled'); if ($count === '0') { $fetchAllPages = true; } @@ -69,9 +80,9 @@ protected function execute(InputInterface $input, OutputInterface $output) } $options['query']['page'] = $pageNumber; - $organization = $this->validateOrganizationInput($input); + $organization = $this->selector->selectOrganization($input); - $httpClient = $this->api()->getHttpClient(); + $httpClient = $this->api->getHttpClient(); $subscriptions = []; $url = $organization->getUri() . '/subscriptions'; $progress = new ProgressMessage($output); @@ -93,7 +104,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln('No subscriptions were found on this page.'); return 0; } - $this->stdErr->writeln(\sprintf('No subscriptions were found belonging to the organization %s.', $this->api()->getOrganizationLabel($organization))); + $this->stdErr->writeln(\sprintf('No subscriptions were found belonging to the organization %s.', $this->api->getOrganizationLabel($organization))); return 0; } @@ -103,20 +114,17 @@ protected function execute(InputInterface $input, OutputInterface $output) $rows[] = $row; } - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - - if (!$table->formatIsMachineReadable()) { - $title = \sprintf('Subscriptions belonging to the organization %s', $this->api()->getOrganizationLabel($organization)); + if (!$this->table->formatIsMachineReadable()) { + $title = \sprintf('Subscriptions belonging to the organization %s', $this->api->getOrganizationLabel($organization)); if (($pageNumber > 1 || isset($collection['next'])) && !$fetchAllPages) { $title .= \sprintf(' (page %d)', $pageNumber); } $this->stdErr->writeln($title); } - $table->render($rows, $this->tableHeader, $this->defaultColumns); + $this->table->render($rows, $this->tableHeader, $this->defaultColumns); - if (!$table->formatIsMachineReadable() && isset($collection['next'])) { + if (!$this->table->formatIsMachineReadable() && isset($collection['next'])) { $this->stdErr->writeln(\sprintf('More subscriptions are available on the next page (--page %d)', $pageNumber + 1)); $this->stdErr->writeln('List all items with: --count 0 (-c0)'); } diff --git a/src/Command/Organization/User/OrganizationUserAddCommand.php b/src/Command/Organization/User/OrganizationUserAddCommand.php index 2d2b40ec01..9098a13e97 100644 --- a/src/Command/Organization/User/OrganizationUserAddCommand.php +++ b/src/Command/Organization/User/OrganizationUserAddCommand.php @@ -1,39 +1,47 @@ setName('organization:user:add') - ->setDescription('Invite a user to an organization') - ->addOrganizationOptions() - ->addArgument('email', InputArgument::OPTIONAL, 'The email address of the user') + $this->selector->addOrganizationOptions($this->getDefinition()); + $this->addCompleter($this->selector); + $this->addArgument('email', InputArgument::OPTIONAL, 'The email address of the user') ->addPermissionOption(); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $organization = $this->validateOrganizationInput($input, 'create-member'); - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); + $organization = $this->selector->selectOrganization($input, 'create-member'); $update = get_called_class() === OrganizationUserUpdateCommand::class; if ($update) { $email = $input->getArgument('email'); if (!empty($email)) { - $existingMember = $this->api()->loadMemberByEmail($organization, $email); + $existingMember = $this->api->loadMemberByEmail($organization, $email); if (!$existingMember) { - $this->stdErr->writeln(sprintf('The user %s was not found in the organization %s', $email, $this->api()->getOrganizationLabel($organization, 'comment'))); + $this->stdErr->writeln(sprintf('The user %s was not found in the organization %s', $email, $this->api->getOrganizationLabel($organization, 'comment'))); return 1; } } elseif (!$input->isInteractive()) { @@ -51,17 +59,15 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln('A user email address is required.'); return 1; } else { - $email = $questionHelper->askInput('Enter the email address of a user to add', null, [], function ($answer) { - return $this->validateEmail($answer); - }); + $email = $this->questionHelper->askInput('Enter the email address of a user to add', null, [], fn($answer) => $this->validateEmail($answer)); $this->stdErr->writeln(''); } } - if (!$update && $this->api()->loadMemberByEmail($organization, $email)) { - $this->stdErr->writeln(\sprintf('The user %s already exists on the organization %s', $email, $this->api()->getOrganizationLabel($organization, 'comment'))); + if (!$update && $this->api->loadMemberByEmail($organization, $email)) { + $this->stdErr->writeln(\sprintf('The user %s already exists on the organization %s', $email, $this->api->getOrganizationLabel($organization, 'comment'))); $this->stdErr->writeln(''); - $this->stdErr->writeln(sprintf('To update the user, run: %s org:user:update %s', $this->config()->get('application.executable'), OsUtil::escapeShellArg($email))); + $this->stdErr->writeln(sprintf('To update the user, run: %s org:user:update %s', $this->config->getStr('application.executable'), OsUtil::escapeShellArg($email))); return 1; } @@ -75,7 +81,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } else { $questionText = 'Optionally, enter a list of permissions to add (separated by commas)'; } - $response = $questionHelper->askInput($questionText, null, [], function ($value) { + $response = $this->questionHelper->askInput($questionText, null, [], function ($value) { foreach (ArrayArgument::split([$value]) as $permission) { if (!\in_array($permission, self::$allPermissions)) { throw new InvalidArgumentException('Unrecognized permission: ' . $permission); @@ -98,7 +104,7 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - $this->stdErr->writeln(\sprintf('Updating the user %s on the organization %s', $this->memberLabel($existingMember), $this->api()->getOrganizationLabel($organization))); + $this->stdErr->writeln(\sprintf('Updating the user %s on the organization %s', $this->memberLabel($existingMember), $this->api->getOrganizationLabel($organization))); $this->stdErr->writeln(''); $this->stdErr->writeln('Summary of changes:'); @@ -119,7 +125,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(''); - if (!$questionHelper->confirm('Are you sure you want to make these changes?')) { + if (!$this->questionHelper->confirm('Are you sure you want to make these changes?')) { return 1; } @@ -127,7 +133,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $new = $result->getProperty('permissions', false) ?: []; $this->stdErr->writeln(\sprintf("The user's permissions are now: %s", $this->listPermissions($new))); - } elseif (!$questionHelper->confirm(\sprintf('Are you sure you want to invite %s to the organization %s?', $email, $this->api()->getOrganizationLabel($organization)))) { + } elseif (!$this->questionHelper->confirm(\sprintf('Are you sure you want to invite %s to the organization %s?', $email, $this->api->getOrganizationLabel($organization)))) { return 1; } else { $invitation = $organization->inviteMemberByEmail($email, $permissions); @@ -147,18 +153,19 @@ protected function execute(InputInterface $input, OutputInterface $output) return 0; } } + return 0; } /** * Validates an email address. * - * @param string $value + * @param string|null $value * - * @throws \Symfony\Component\Console\Exception\InvalidArgumentException + * @throws InvalidArgumentException * * @return string */ - private function validateEmail($value) + private function validateEmail(?string $value): string { if (empty($value)) { throw new InvalidArgumentException('An email address is required.'); diff --git a/src/Command/Organization/User/OrganizationUserCommandBase.php b/src/Command/Organization/User/OrganizationUserCommandBase.php index 69ac84c12f..b3e78e6fb9 100644 --- a/src/Command/Organization/User/OrganizationUserCommandBase.php +++ b/src/Command/Organization/User/OrganizationUserCommandBase.php @@ -1,16 +1,18 @@ [none]'; @@ -33,27 +35,16 @@ protected function listPermissions($permissions = null) * * @return $this */ - protected function addPermissionOption() + protected function addPermissionOption(): Command { return $this->addOption( 'permission', null, - InputOption::VALUE_REQUIRED|InputOption::VALUE_IS_ARRAY, + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Permission(s) for the user on the organization. ' - . "\n" . 'Valid permissions are: ' . implode(', ', self::$allPermissions) . '' + . "\n" . 'Valid permissions are: ' . implode(', ', self::$allPermissions) . '', + null, + self::$allPermissions, ); } - - public function completeOptionValues($optionName, CompletionContext $context) - { - if ($optionName === 'permission') { - return self::$allPermissions; - } - return []; - } - - public function completeArgumentValues($argumentName, CompletionContext $context) - { - return []; - } } diff --git a/src/Command/Organization/User/OrganizationUserDeleteCommand.php b/src/Command/Organization/User/OrganizationUserDeleteCommand.php index e94173bc57..c4c0a51ea5 100644 --- a/src/Command/Organization/User/OrganizationUserDeleteCommand.php +++ b/src/Command/Organization/User/OrganizationUserDeleteCommand.php @@ -1,38 +1,45 @@ setName('organization:user:delete') - ->setDescription('Remove a user from an organization') - ->addOrganizationOptions() - ->addArgument('email', InputArgument::REQUIRED, 'The email address of the user'); + $this->selector->addOrganizationOptions($this->getDefinition()); + $this->addCompleter($this->selector); + $this->addArgument('email', InputArgument::REQUIRED, 'The email address of the user'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { // The 'create-member' link shows the user has the ability to read/write members. - $organization = $this->validateOrganizationInput($input, 'create-member'); + $organization = $this->selector->selectOrganization($input, 'create-member'); $email = $input->getArgument('email'); - $member = $this->api()->loadMemberByEmail($organization, $email); + $member = $this->api->loadMemberByEmail($organization, $email); if (!$member) { $this->stdErr->writeln(\sprintf('User not found: %s', $email)); return 1; } - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - if (!$questionHelper->confirm(\sprintf('Are you sure you want to delete the user %s from the organization %s?', $email, $this->api()->getOrganizationLabel($organization, 'comment')))) { + if (!$this->questionHelper->confirm(\sprintf('Are you sure you want to delete the user %s from the organization %s?', $email, $this->api->getOrganizationLabel($organization, 'comment')))) { return 1; } diff --git a/src/Command/Organization/User/OrganizationUserGetCommand.php b/src/Command/Organization/User/OrganizationUserGetCommand.php index f9bb9d7a62..31556289ef 100644 --- a/src/Command/Organization/User/OrganizationUserGetCommand.php +++ b/src/Command/Organization/User/OrganizationUserGetCommand.php @@ -1,43 +1,52 @@ setName('organization:user:get') - ->setDescription('View an organization user') - ->addOrganizationOptions() - ->addArgument('email', InputArgument::OPTIONAL, 'The email address of the user') + $this->selector->addOrganizationOptions($this->getDefinition()); + $this->addCompleter($this->selector); + $this->addArgument('email', InputArgument::OPTIONAL, 'The email address of the user') ->addOption('property', 'P', InputOption::VALUE_REQUIRED, 'A property to display'); PropertyFormatter::configureInput($this->getDefinition()); Table::configureInput($this->getDefinition()); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $organization = $this->validateOrganizationInput($input, 'members'); + $organization = $this->selector->selectOrganization($input, 'members'); if (!$organization->hasLink('members')) { - $this->stdErr->writeln('You do not have permission to view users in the organization ' . $this->api()->getOrganizationLabel($organization, 'comment') . '.'); + $this->stdErr->writeln('You do not have permission to view users in the organization ' . $this->api->getOrganizationLabel($organization, 'comment') . '.'); return 1; } $email = $input->getArgument('email'); if (!empty($email)) { - $member = $this->api()->loadMemberByEmail($organization, $email); + $member = $this->api->loadMemberByEmail($organization, $email); if (!$member) { $this->stdErr->writeln(\sprintf('User not found: %s', $email)); return 1; @@ -49,9 +58,6 @@ protected function execute(InputInterface $input, OutputInterface $output) $member = $this->chooseMember($organization); } - /** @var PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - $data = $member->getProperties(); $memberInfo = $member->getUserInfo(); @@ -62,27 +68,24 @@ protected function execute(InputInterface $input, OutputInterface $output) } if ($input->getOption('property')) { - $formatter->displayData($output, $data, $input->getOption('property')); + $this->propertyFormatter->displayData($output, $data, $input->getOption('property')); return 0; } - /** @var Table $table */ - $table = $this->getService('table'); - - if (!$table->formatIsMachineReadable()) { - $this->stdErr->writeln(\sprintf('Viewing the user %s on the organization %s', $this->memberLabel($member), $this->api()->getOrganizationLabel($organization))); + if (!$this->table->formatIsMachineReadable()) { + $this->stdErr->writeln(\sprintf('Viewing the user %s on the organization %s', $this->memberLabel($member), $this->api->getOrganizationLabel($organization))); } $headings = []; $values = []; foreach ($data as $key => $value) { $headings[] = new AdaptiveTableCell($key, ['wrap' => false]); - $values[] = $formatter->format($value, $key); + $values[] = $this->propertyFormatter->format($value, $key); } - $table->renderSimple($values, $headings); + $this->table->renderSimple($values, $headings); - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->stdErr->writeln(\sprintf("To view the user's project access, run: %s", $this->otherCommandExample($input, 'org:user:projects', $memberInfo ? OsUtil::escapeShellArg($memberInfo->email) : ''))); } diff --git a/src/Command/Organization/User/OrganizationUserListCommand.php b/src/Command/Organization/User/OrganizationUserListCommand.php index 84590099d1..a2534790db 100644 --- a/src/Command/Organization/User/OrganizationUserListCommand.php +++ b/src/Command/Organization/User/OrganizationUserListCommand.php @@ -1,19 +1,27 @@ */ + private array $tableHeader = [ 'id' => 'ID', 'first_name' => 'First name', 'last_name' => 'Last name', @@ -26,35 +34,39 @@ class OrganizationUserListCommand extends OrganizationCommandBase 'created_at' => 'Created at', 'updated_at' => 'Updated at', ]; - private $defaultColumns = ['id', 'email', 'owner', 'permissions']; + /** @var string[] */ + private array $defaultColumns = ['id', 'email', 'owner', 'permissions']; - protected function configure() + public function __construct(private readonly Api $api, private readonly Config $config, private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table) { - $this->setName('organization:user:list') - ->setDescription('List organization users') - ->addOption('count', 'c', InputOption::VALUE_REQUIRED, 'The number of items to display per page. Use 0 to disable pagination.') + parent::__construct(); + } + + protected function configure(): void + { + $this->addOption('count', 'c', InputOption::VALUE_REQUIRED, 'The number of items to display per page. Use 0 to disable pagination.') ->addOption('sort', null, InputOption::VALUE_REQUIRED, 'A property to sort by (created_at or updated_at)', 'created_at') ->addOption('reverse', null, InputOption::VALUE_NONE, 'Reverse the sort order') - ->setAliases(['org:users']) - ->setHiddenAliases(['organization:users']) - ->addOrganizationOptions(); + ->setHiddenAliases(['organization:users']); + $this->selector->addOrganizationOptions($this->getDefinition(), true); + $this->addCompleter($this->selector); PropertyFormatter::configureInput($this->getDefinition()); Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $organization = $this->validateOrganizationInput($input, 'members'); + $organization = $this->selector->selectOrganization($input, 'members'); if (!$organization->hasLink('members')) { - $this->stdErr->writeln('You do not have permission to view users in the organization ' . $this->api()->getOrganizationLabel($organization, 'comment') . '.'); + $this->stdErr->writeln('You do not have permission to view users in the organization ' . $this->api->getOrganizationLabel($organization, 'comment') . '.'); return 1; } $options = []; $count = $input->getOption('count'); - $itemsPerPage = (int) $this->config()->getWithDefault('pagination.count', 20); + $itemsPerPage = $this->config->getInt('pagination.count'); if ($count !== null && $count !== '0') { if (!\is_numeric($count) || $count > 100) { $this->stdErr->writeln('The --count must be a number between 1 and 100, or 0 to disable pagination.'); @@ -71,13 +83,13 @@ protected function execute(InputInterface $input, OutputInterface $output) } $options['query']['page[size]'] = $itemsPerPage; - $fetchAllPages = !$this->config()->getWithDefault('pagination.enabled', true); + $fetchAllPages = !$this->config->getBool('pagination.enabled'); if ($count === '0') { $fetchAllPages = true; $options['query']['page[size]'] = 100; } - $httpClient = $this->api()->getHttpClient(); + $httpClient = $this->api->getHttpClient(); $url = $organization->getLink('members'); /** @var Member[] $members */ $members = []; @@ -98,9 +110,6 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - /** @var PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - $rows = []; foreach ($members as $member) { $userInfo = $member->getUserInfo(); @@ -110,35 +119,33 @@ protected function execute(InputInterface $input, OutputInterface $output) 'last_name' => $userInfo ? $userInfo->last_name : '', 'email' => $userInfo ? $userInfo->email : '', 'username' => $userInfo ? $userInfo->username : '', - 'owner' => $formatter->format($member->owner, 'owner'), - 'mfa_enabled' => $userInfo && isset($userInfo->mfa_enabled) ? $formatter->format($userInfo->mfa_enabled, 'mfa_enabled') : '', - 'sso_enabled' => $userInfo && isset($userInfo->sso_enabled) ? $formatter->format($userInfo->sso_enabled, 'sso_enabled') : '', - 'permissions' => $formatter->format($member->permissions, 'permissions'), - 'updated_at' => $formatter->format($member->updated_at, 'updated_at'), - 'created_at' => $formatter->format($member->created_at, 'created_at'), + 'owner' => $this->propertyFormatter->format($member->owner, 'owner'), + 'mfa_enabled' => $userInfo && isset($userInfo->mfa_enabled) ? $this->propertyFormatter->format($userInfo->mfa_enabled, 'mfa_enabled') : '', + 'sso_enabled' => $userInfo && isset($userInfo->sso_enabled) ? $this->propertyFormatter->format($userInfo->sso_enabled, 'sso_enabled') : '', + 'permissions' => $this->propertyFormatter->format($member->permissions, 'permissions'), + 'updated_at' => $this->propertyFormatter->format($member->updated_at, 'updated_at'), + 'created_at' => $this->propertyFormatter->format($member->created_at, 'created_at'), ]; $rows[] = $row; } - /** @var Table $table */ - $table = $this->getService('table'); - if (!$table->formatIsMachineReadable()) { - $this->stdErr->writeln('Users in the organization ' . $this->api()->getOrganizationLabel($organization) . ':'); + if (!$this->table->formatIsMachineReadable()) { + $this->stdErr->writeln('Users in the organization ' . $this->api->getOrganizationLabel($organization) . ':'); } - $table->render($rows, $this->tableHeader, $this->defaultColumns); + $this->table->render($rows, $this->tableHeader, $this->defaultColumns); $total = $result['collection']->getTotalCount(); $moreAvailable = !$fetchAllPages && $total > count($members); if ($moreAvailable) { - if (!$table->formatIsMachineReadable() || $this->stdErr->isDecorated()) { + if (!$this->table->formatIsMachineReadable() || $this->stdErr->isDecorated()) { $this->stdErr->writeln(''); } $this->stdErr->writeln(sprintf('More users are available (displaying %d, total %d)', count($members), $total)); $this->stdErr->writeln('Show all users with: --count 0 (-c0)'); } - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->stdErr->writeln(''); $this->stdErr->writeln(\sprintf('To get full user details, run: %s', $this->otherCommandExample($input, 'org:user:get', '[email]'))); $this->stdErr->writeln(\sprintf('To add a user, run: %s', $this->otherCommandExample($input, 'org:user:add', '[email]'))); diff --git a/src/Command/Organization/User/OrganizationUserProjectsCommand.php b/src/Command/Organization/User/OrganizationUserProjectsCommand.php index 60a8264069..824a1fc5cf 100644 --- a/src/Command/Organization/User/OrganizationUserProjectsCommand.php +++ b/src/Command/Organization/User/OrganizationUserProjectsCommand.php @@ -1,7 +1,13 @@ */ + protected array $tableHeader = [ 'organization_id' => 'Organization ID', 'organization_name' => 'Organization', 'organization_label' => 'Organization label', @@ -28,47 +37,53 @@ class OrganizationUserProjectsCommand extends OrganizationCommandBase 'updated_at' => 'Updated at', 'region' => 'Region', ]; - protected $defaultColumns = ['project_id', 'project_title', 'roles', 'updated_at']; - public function isEnabled() + /** @var string[] */ + protected array $defaultColumns = ['project_id', 'project_title', 'roles', 'updated_at']; + + public function __construct(private readonly Api $api, private readonly Config $config, private readonly Io $io, private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table) + { + parent::__construct(); + } + + public function isEnabled(): bool { - return $this->config()->get('api.centralized_permissions') - && $this->config()->get('api.organizations') + return $this->config->getBool('api.centralized_permissions') + && $this->config->getBool('api.organizations') && parent::isEnabled(); } - protected function configure() + protected function configure(): void { - $this->setName('organization:user:projects') - ->setAliases(['oups']) + $this ->addArgument('email', InputArgument::OPTIONAL, 'The email address of the user') ->addHiddenOption('sort-granted', null, InputOption::VALUE_NONE, 'Deprecated option: unused') ->addHiddenOption('reverse', null, InputOption::VALUE_NONE, 'Deprecated option: unused'); - $this->setDescription('List the projects a user can access'); - $this->addOrganizationOptions(); + $this->selector->addOrganizationOptions($this->getDefinition()); + $this->addCompleter($this->selector); $this->addOption('list-all', null, InputOption::VALUE_NONE, 'List access across all organizations'); Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); PropertyFormatter::configureInput($this->getDefinition()); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $organization = null; if (!$input->getOption('list-all')) { - $organization = $this->validateOrganizationInput($input, 'members'); + $organization = $this->selector->selectOrganization($input, 'members'); if (!$organization->hasLink('members')) { - $this->stdErr->writeln('You do not have permission to view users in the organization ' . $this->api()->getOrganizationLabel($organization, 'comment') . '.'); + $this->stdErr->writeln('You do not have permission to view users in the organization ' . $this->api->getOrganizationLabel($organization, 'comment') . '.'); return 1; } } if ($email = $input->getArgument('email')) { if (!$organization) { - $this->debug('Finding user by email address'); - $user = $this->api()->getUser('email=' . $email); + $this->io->debug('Finding user by email address'); + $user = $this->api->getUser('email=' . $email); $userId = $user->id; $userRef = UserRef::fromData($user->getData()); } else { - $member = $this->api()->loadMemberByEmail($organization, $email); + $member = $this->api->loadMemberByEmail($organization, $email); if (!$member) { $this->stdErr->writeln('User not found for email address: ' . $email); return 1; @@ -92,7 +107,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $options['query']['filter[resource_type]'] = 'project'; - $httpClient = $this->api()->getHttpClient(); + $httpClient = $this->api->getHttpClient(); /** @var UserExtendedAccess[] $items */ $items = []; $url = '/users/' . rawurlencode($userId) . '/extended-access'; @@ -117,18 +132,13 @@ protected function execute(InputInterface $input, OutputInterface $output) return 0; } if ($organization) { - $this->stdErr->writeln(\sprintf('No projects were found for the user %s in the organization %s.', $this->api()->getUserRefLabel($userRef), $this->api()->getOrganizationLabel($organization))); + $this->stdErr->writeln(\sprintf('No projects were found for the user %s in the organization %s.', $this->api->getUserRefLabel($userRef), $this->api->getOrganizationLabel($organization))); } else { - $this->stdErr->writeln(\sprintf('No projects were found for the user %s.', $this->api()->getUserRefLabel($userRef))); + $this->stdErr->writeln(\sprintf('No projects were found for the user %s.', $this->api->getUserRefLabel($userRef))); } return 0; } - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - $rolesUtil = new ProjectRoles(); $rows = []; @@ -136,9 +146,9 @@ protected function execute(InputInterface $input, OutputInterface $output) $row = []; $row['organization_id'] = $item->organization_id; $row['project_id'] = $item->resource_id; - $row['roles'] = $rolesUtil->formatPermissions($item->permissions, $table->formatIsMachineReadable()); - $row['granted_at'] = $formatter->format($item->granted_at, 'granted_at'); - $row['updated_at'] = $formatter->format($item->updated_at, 'updated_at'); + $row['roles'] = $rolesUtil->formatPermissions($item->permissions, $this->table->formatIsMachineReadable()); + $row['granted_at'] = $this->propertyFormatter->format($item->granted_at, 'granted_at'); + $row['updated_at'] = $this->propertyFormatter->format($item->updated_at, 'updated_at'); $projectInfo = $item->getProjectInfo(); $row['project_title'] = $projectInfo ? $projectInfo->title : ''; $row['region'] = $projectInfo ? $projectInfo->region : ''; @@ -148,17 +158,17 @@ protected function execute(InputInterface $input, OutputInterface $output) $rows[] = $row; } - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { if ($organization) { $this->stdErr->writeln(\sprintf( 'Project access for the user %s in the organization %s:', - $this->api()->getUserRefLabel($userRef), - $this->api()->getOrganizationLabel($organization) + $this->api->getUserRefLabel($userRef), + $this->api->getOrganizationLabel($organization), )); } else { $this->stdErr->writeln(\sprintf( 'All project access for the user %s:', - $this->api()->getUserRefLabel($userRef) + $this->api->getUserRefLabel($userRef), )); } } @@ -168,9 +178,9 @@ protected function execute(InputInterface $input, OutputInterface $output) $defaultColumns[] = 'organization_name'; } - $table->render($rows, $this->tableHeader, $defaultColumns); + $this->table->render($rows, $this->tableHeader, $defaultColumns); - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->stdErr->writeln(\sprintf('To view the user details, run: %s', $this->otherCommandExample($input, 'org:user:get', OsUtil::escapeShellArg($userRef->email)))); } diff --git a/src/Command/Organization/User/OrganizationUserUpdateCommand.php b/src/Command/Organization/User/OrganizationUserUpdateCommand.php index 053a9ee8b6..fd56eae84c 100644 --- a/src/Command/Organization/User/OrganizationUserUpdateCommand.php +++ b/src/Command/Organization/User/OrganizationUserUpdateCommand.php @@ -1,17 +1,20 @@ setName('organization:user:update') - ->setDescription('Update an organization user') - ->addOrganizationOptions() - ->addArgument('email', InputArgument::OPTIONAL, 'The email address of the user') + $this->selector->addOrganizationOptions($this->getDefinition()); + $this->addCompleter($this->selector); + $this->addArgument('email', InputArgument::OPTIONAL, 'The email address of the user') ->addPermissionOption(); } } diff --git a/src/Command/Project/ProjectClearBuildCacheCommand.php b/src/Command/Project/ProjectClearBuildCacheCommand.php index 1ec204b067..9499ae40b5 100644 --- a/src/Command/Project/ProjectClearBuildCacheCommand.php +++ b/src/Command/Project/ProjectClearBuildCacheCommand.php @@ -1,26 +1,35 @@ setName('project:clear-build-cache') - ->setDescription("Clear a project's build cache"); - $this->addProjectOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); - $project = $this->getSelectedProject(); + $selection = $this->selector->getSelection($input); + $project = $selection->getProject(); $project->clearBuildCache(); - $this->stdErr->writeln('The build cache has been cleared on the project: ' . $this->api()->getProjectLabel($project)); + $this->stdErr->writeln('The build cache has been cleared on the project: ' . $this->api->getProjectLabel($project)); return 0; } diff --git a/src/Command/Project/ProjectCreateCommand.php b/src/Command/Project/ProjectCreateCommand.php index 20a8992667..bde663d8ce 100644 --- a/src/Command/Project/ProjectCreateCommand.php +++ b/src/Command/Project/ProjectCreateCommand.php @@ -1,7 +1,17 @@ setName('project:create') - ->setAliases(['create']) - ->setDescription('Create a new project'); + parent::__construct(); + } - $this->addOrganizationOptions(); + protected function configure(): void + { + $this->selector->addOrganizationOptions($this->getDefinition()); Form::fromArray($this->getFields())->configureInputDefinition($this->getDefinition()); @@ -48,57 +61,52 @@ protected function configure() $this->addHiddenOption('check-timeout', null, InputOption::VALUE_REQUIRED, 'The API timeout while checking the project status', 30) ->addHiddenOption('timeout', null, InputOption::VALUE_REQUIRED, 'The total timeout for all API checks (0 to disable the timeout)', 900); - $this->setHelp(<<setHelp( + <<config()->getWithDefault('api.organizations', false); + $organizationsEnabled = $this->config->getBool('api.organizations'); // Check if the user needs phone verification before creating a project. if (!$organizationsEnabled) { - $needsVerify = $this->api()->checkUserVerification(); + $needsVerify = $this->api->checkUserVerification(); if ($needsVerify['state'] && !$this->requireVerification($needsVerify['type'], '', $input)) { return 1; } } - /** @var \Platformsh\Cli\Service\Git $git */ - $git = $this->getService('git'); - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - // Identify an organization that should own the project. $organization = null; $setupOptions = null; - if ($this->config()->getWithDefault('api.organizations', false)) { + if ($this->config->getBool('api.organizations')) { try { - $organization = $this->validateOrganizationInput($input, 'create-subscription'); + $organization = $this->selector->selectOrganization($input, 'create-subscription'); } catch (NoOrganizationsException $e) { $this->stdErr->writeln('You do not yet own nor belong to an organization in which you can create a project.'); - if ($e->getTotalNumOrgs() === 0 && $input->isInteractive() && $this->config()->isCommandEnabled('organization:create') && $questionHelper->confirm('Do you want to create an organization now?')) { - if ($this->runOtherCommand('organization:create') !== 0) { + if ($e->getTotalNumOrgs() === 0 && $input->isInteractive() && $this->config->isCommandEnabled('organization:create') && $this->questionHelper->confirm('Do you want to create an organization now?')) { + if ($this->subCommandRunner->run('organization:create') !== 0) { return 1; } - $organization = $this->validateOrganizationInput($input, 'create-subscription'); + $organization = $this->selector->selectOrganization($input, 'create-subscription'); } else { return 1; } @@ -108,7 +116,7 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - $this->stdErr->writeln('Creating a project under the organization ' . $this->api()->getOrganizationLabel($organization)); + $this->stdErr->writeln('Creating a project under the organization ' . $this->api->getOrganizationLabel($organization)); $this->stdErr->writeln(''); $setupOptions = $organization->getSetupOptions(); @@ -116,8 +124,8 @@ protected function execute(InputInterface $input, OutputInterface $output) // Validate the --set-remote option. $setRemote = (bool) $input->getOption('set-remote'); - $projectRoot = $this->getProjectRoot(); - $gitRoot = $projectRoot !== false ? $projectRoot : $git->getRoot(); + $projectRoot = $this->selector->getProjectRoot(); + $gitRoot = $projectRoot !== false ? $projectRoot : $this->git->getRoot(); if ($setRemote && $gitRoot === false) { $this->stdErr->writeln('The --set-remote option can only be used inside a Git repository directory.'); $this->stdErr->writeln('Use git init to create a repository.'); @@ -126,15 +134,15 @@ protected function execute(InputInterface $input, OutputInterface $output) } $form = Form::fromArray($this->getFields($setupOptions)); - $options = $form->resolveOptions($input, $output, $questionHelper); + $options = $form->resolveOptions($input, $output, $this->questionHelper); if ($gitRoot !== false && !$input->getOption('no-set-remote')) { try { - $currentProject = $this->getCurrentProject(); - } catch (ProjectNotFoundException $e) { + $currentProject = $this->selector->getCurrentProject(); + } catch (ProjectNotFoundException) { $currentProject = false; } catch (BadResponseException $e) { - if ($e->getResponse() && $e->getResponse()->getStatusCode() === 403) { + if ($e->getResponse()->getStatusCode() === 403) { $currentProject = false; } else { throw $e; @@ -143,22 +151,22 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln('Local Git repository detected: ' . $gitRoot . ''); if ($currentProject) { - $this->stdErr->writeln(sprintf('The remote project is currently: %s', $this->api()->getProjectLabel($currentProject, 'comment'))); + $this->stdErr->writeln(sprintf('The remote project is currently: %s', $this->api->getProjectLabel($currentProject, 'comment'))); } $this->stdErr->writeln(''); if ($setRemote) { $this->stdErr->writeln(sprintf('The new project %s will be set as the remote for this repository directory.', $options['title'])); } elseif ($currentProject) { - $setRemote = $questionHelper->confirm(sprintf( + $setRemote = $this->questionHelper->confirm(sprintf( 'Switch the remote project for this repository directory from %s to the new project %s?', - $this->api()->getProjectLabel($currentProject, false), - $options['title'] + $this->api->getProjectLabel($currentProject, false), + $options['title'], ), false); } else { - $setRemote = $questionHelper->confirm(sprintf( + $setRemote = $this->questionHelper->confirm(sprintf( 'Set the new project %s as the remote for this repository directory?', - $options['title'] + $options['title'], )); } $this->stdErr->writeln(''); @@ -172,27 +180,27 @@ protected function execute(InputInterface $input, OutputInterface $output) $options_custom['initialize']['repository'] = $options['init_repo']; } - $estimate = $this->api() + $estimate = $this->api ->getClient() - ->getSubscriptionEstimate($options['plan'], (int) $options['storage'] * 1024, (int) $options['environments'], 1, null, $organization ? $organization->id : null); + ->getSubscriptionEstimate($options['plan'], (int) $options['storage'] * 1024, (int) $options['environments'], 1, null, $organization?->id); $costConfirm = sprintf( 'The estimated monthly cost of this project is: %s', - $estimate['total'] + $estimate['total'], ); - if ($this->config()->has('service.pricing_url')) { + if ($this->config->has('service.pricing_url')) { $costConfirm .= sprintf( "\nPricing information: %s", - $this->config()->get('service.pricing_url') + $this->config->getStr('service.pricing_url'), ); } $costConfirm .= "\n\nAre you sure you want to continue?"; - if (!$questionHelper->confirm($costConfirm)) { + if (!$this->questionHelper->confirm($costConfirm)) { return 1; } - $subscription = $this->api()->getClient() + $subscription = $this->api->getClient() ->createSubscription(SubscriptionOptions::fromArray([ - 'organization_id' => $organization ? $organization->id : null, + 'organization_id' => $organization?->id, 'project_title' => $options['title'], 'project_region' => $options['region'], 'default_branch' => $options['default_branch'], @@ -200,19 +208,20 @@ protected function execute(InputInterface $input, OutputInterface $output) 'storage' => (int) $options['storage'] * 1024, 'environments' => (int) $options['environments'], 'options_custom' => $options_custom, + 'options_url' => null, ])); - $this->api()->clearProjectsCache(); + $this->api->clearProjectsCache(); $this->stdErr->writeln(sprintf( 'Your %s project has been requested (subscription ID: %s)', - $this->config()->get('service.name'), - $subscription->id + $this->config->getStr('service.name'), + $subscription->id, )); $this->stdErr->writeln(sprintf( "\nThe %s Bot is activating your project\n", - $this->config()->get('service.name') + $this->config->getStr('service.name'), )); $bot = new Bot($this->stdErr); @@ -233,14 +242,14 @@ protected function execute(InputInterface $input, OutputInterface $output) // The API call will timeout after $checkTimeout seconds. $subscription->refresh(['timeout' => $checkTimeout]); } catch (ConnectException $e) { - if (strpos($e->getMessage(), 'timed out') !== false) { - $this->debug($e->getMessage()); + if (str_contains($e->getMessage(), 'timed out')) { + $this->io->debug($e->getMessage()); } else { throw $e; } } catch (BadResponseException $e) { - if ($e->getResponse() && in_array($e->getResponse()->getStatusCode(), [502, 503, 524])) { - $this->debug($e->getMessage()); + if (in_array($e->getResponse()->getStatusCode(), [502, 503, 524])) { + $this->io->debug($e->getMessage()); } else { throw $e; } @@ -264,49 +273,15 @@ protected function execute(InputInterface $input, OutputInterface $output) $output->writeln($subscription->project_id); } - $this->stdErr->writeln(sprintf('View your active projects with: %s project:list', $this->config()->get('application.executable'))); + $this->stdErr->writeln(sprintf('View your active projects with: %s project:list', $this->config->getStr('application.executable'))); return 1; } - $progressMessage = new ProgressMessage($this->stdErr); - $checkInterval = 1; - $lastCheck = time(); - $progressMessage->show('Loading project information...'); - $project = false; - while (true) { - if (time() - $lastCheck >= $checkInterval) { - $lastCheck = time(); - try { - $project = $this->api()->getProject($subscription->project_id); - if ($project !== false) { - break; - } else { - $this->debug(sprintf('Project not found: %s (retrying)', $subscription->project_id)); - } - } catch (ConnectException $e) { - if (strpos($e->getMessage(), 'timed out') !== false) { - $this->debug($e->getMessage()); - } else { - throw $e; - } - } catch (BadResponseException $e) { - if ($e->getResponse() && in_array($e->getResponse()->getStatusCode(), [403, 502, 524])) { - $this->debug(sprintf('Received status code %d from project: %s (retrying)', $e->getResponse()->getStatusCode(), $subscription->project_id)); - } else { - throw $e; - } - } - usleep(200000); - } - if ($totalTimeout && time() - $start > $totalTimeout) { - $progressMessage->done(); - $this->stdErr->writeln(sprintf('The subscription is active but the project %s could not be fetched.', $subscription->project_id)); - $this->stdErr->writeln('The project may be accessible momentarily. Otherwise, please contact support.'); - return 1; - } + $project = $this->waitForProject($subscription, $totalTimeout, $start); + if (!$project) { + return 1; } - $progressMessage->done(); $this->stdErr->writeln("The project is now ready!"); $output->writeln($subscription->project_id); @@ -317,7 +292,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(" Project title: {$subscription->project_title}"); $this->stdErr->writeln(''); - $this->stdErr->writeln(sprintf(" Console URL: %s", $this->api()->getConsoleURL($project))); + $this->stdErr->writeln(sprintf(" Console URL: %s", $this->api->getConsoleURL($project))); $this->stdErr->writeln(" Git URL: {$project->getGitUrl()}"); @@ -325,39 +300,75 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf( 'Setting the remote project for this repository to: %s', - $this->api()->getProjectLabel($project) + $this->api->getProjectLabel($project), )); - /** @var \Platformsh\Cli\Local\LocalProject $localProject */ - $localProject = $this->getService('local.project'); + $localProject = $this->localProject; $localProject->mapDirectory($gitRoot, $project); } if ($gitRoot === false) { $this->stdErr->writeln(''); - $this->stdErr->writeln(sprintf('To clone the project locally, run: %s get %s', $this->config()->get('application.executable'), OsUtil::escapeShellArg($project->id))); + $this->stdErr->writeln(sprintf('To clone the project locally, run: %s get %s', $this->config->getStr('application.executable'), OsUtil::escapeShellArg($project->id))); } return 0; } + private function waitForProject(Subscription $subscription, int|float $totalTimeout, float $start): Project|false + { + $progressMessage = new ProgressMessage($this->stdErr); + $checkInterval = 1; + $lastCheck = time(); + $progressMessage->show('Loading project information...'); + while (true) { + if (time() - $lastCheck >= $checkInterval) { + $lastCheck = time(); + try { + $project = $this->api->getProject($subscription->project_id); + if ($project !== false) { + $progressMessage->done(); + return $project; + } else { + $this->io->debug(sprintf('Project not found: %s (retrying)', $subscription->project_id)); + } + } catch (ConnectException $e) { + if (str_contains($e->getMessage(), 'timed out')) { + $this->io->debug($e->getMessage()); + } else { + throw $e; + } + } catch (BadResponseException $e) { + if (in_array($e->getResponse()->getStatusCode(), [403, 502, 524])) { + $this->io->debug(sprintf('Received status code %d from project: %s (retrying)', $e->getResponse()->getStatusCode(), $subscription->project_id)); + } else { + throw $e; + } + } + usleep(200000); + } + if ($totalTimeout && time() - $start > $totalTimeout) { + $progressMessage->done(); + $this->stdErr->writeln(sprintf('The subscription is active but the project %s could not be fetched.', $subscription->project_id)); + $this->stdErr->writeln('The project may be accessible momentarily. Otherwise, please contact support.'); + return false; + } + } + } + /** * Checks the organization /can-create API before creating a project. * * This will show whether billing changes or verification are needed. - * - * @param Organization $organization - * @param InputInterface $input - * @return bool */ - private function checkCanCreate(Organization $organization, InputInterface $input) + private function checkCanCreate(Organization $organization, InputInterface $input): bool { - $canCreate = $this->api()->checkCanCreate($organization); + $canCreate = $this->api->checkCanCreate($organization); if ($canCreate['can_create']) { return true; } if ($canCreate['required_action']) { - $consoleUrl = $this->config()->getWithDefault('service.console_url', ''); + $consoleUrl = $this->config->getStr('service.console_url'); if ($consoleUrl && $canCreate['required_action']['action'] === 'billing_details') { $this->stdErr->writeln($canCreate['message']); $this->stdErr->writeln(''); @@ -382,25 +393,20 @@ private function checkCanCreate(Organization $organization, InputInterface $inpu /** * Requires phone or support verification. - * - * @param string $type - * @param string $message - * @param InputInterface $input - * @return bool True if verification succeeded, false otherwise. */ - private function requireVerification($type, $message, InputInterface $input) + private function requireVerification(string $type, string $message, InputInterface $input): bool { if ($type === 'phone') { $this->stdErr->writeln('Phone number verification is required before creating a project.'); if ($input->isInteractive()) { $this->stdErr->writeln(''); - $exitCode = $this->runOtherCommand('auth:verify-phone-number'); + $exitCode = $this->subCommandRunner->run('auth:verify-phone-number'); if ($exitCode === 0) { $this->stdErr->writeln(''); return true; } - } elseif ($this->config()->has('service.console_url')) { - $url = $this->config()->get('service.console_url') . '/-/phone-verify'; + } elseif ($this->config->has('service.console_url')) { + $url = $this->config->getStr('service.console_url') . '/-/phone-verify'; $this->stdErr->writeln(''); $this->stdErr->writeln('Please open the following URL in a browser to verify your phone number:'); $this->stdErr->writeln(sprintf('%s', $url)); @@ -408,15 +414,15 @@ private function requireVerification($type, $message, InputInterface $input) } } elseif ($type === 'credit-card') { $this->stdErr->writeln('Credit card verification is required before creating a project.'); - if ($this->config()->has('service.console_url')) { + if ($this->config->has('service.console_url')) { $this->stdErr->writeln(''); $this->stdErr->writeln('Please use Console to create your first project:'); - $this->stdErr->writeln(sprintf('%s', $this->config()->get('service.console_url'))); + $this->stdErr->writeln(sprintf('%s', $this->config->getStr('service.console_url'))); } } elseif ($type === 'support' || $type === 'ticket') { $this->stdErr->writeln('Verification via a support ticket is required before creating a project.'); - if ($this->config()->has('service.console_url')) { - $url = $this->config()->get('service.console_url') . '/support'; + if ($this->config->has('service.console_url')) { + $url = $this->config->getStr('service.console_url') . '/support'; $this->stdErr->writeln(''); $this->stdErr->writeln('Please open the following URL in a browser to create a ticket:'); $this->stdErr->writeln(sprintf('%s', $url)); @@ -432,10 +438,10 @@ private function requireVerification($type, $message, InputInterface $input) * * @param SetupOptions|null $setupOptions * - * @return array + * @return string[] * A list of plan machine names. */ - protected function getAvailablePlans(SetupOptions $setupOptions = null) + protected function getAvailablePlans(?SetupOptions $setupOptions = null): array { if (isset($setupOptions)) { return $setupOptions->plans; @@ -444,7 +450,7 @@ protected function getAvailablePlans(SetupOptions $setupOptions = null) return $this->plansCache; } $plans = []; - foreach ($this->api()->getClient()->getPlans() as $plan) { + foreach ($this->api->getClient()->getPlans() as $plan) { $plans[] = $plan->name; } return $this->plansCache = $plans; @@ -454,9 +460,8 @@ protected function getAvailablePlans(SetupOptions $setupOptions = null) * Picks a default plan from a list. * * @param string[] $availablePlans - * @return string|null */ - protected function getDefaultPlan($availablePlans) + protected function getDefaultPlan(array $availablePlans): ?string { if (count($availablePlans) === 1) { return reset($availablePlans); @@ -475,11 +480,11 @@ protected function getDefaultPlan($availablePlans) * @return array * A list of region names, mapped to option names. */ - protected function getAvailableRegions(SetupOptions $setupOptions = null) + protected function getAvailableRegions(?SetupOptions $setupOptions = null): array { $regions = $this->regionsCache !== null ? $this->regionsCache - : $this->regionsCache = $this->api()->getClient()->getRegions(); + : $this->regionsCache = $this->api->getClient()->getRegions(); $available = []; if (isset($setupOptions)) { $available = $setupOptions->regions; @@ -491,7 +496,7 @@ protected function getAvailableRegions(SetupOptions $setupOptions = null) } } - \usort($available, [Sort::class, 'compareDomains']); + \usort($available, Sort::compareDomains(...)); $options = []; foreach ($available as $id) { @@ -509,12 +514,8 @@ protected function getAvailableRegions(SetupOptions $setupOptions = null) /** * Outputs a short description of a region, including its location and carbon intensity. - * - * @param Region $region - * - * @return string */ - private function regionInfo(Region $region) + private function regionInfo(Region $region): string { $green = !empty($region->environmental_impact['green']); if (!empty($region->datacenter['location'])) { @@ -523,7 +524,7 @@ private function regionInfo(Region $region) $info = $region->id; } if (!empty($region->provider['name'])) { - $info .= ' ' .\sprintf('(%s)', $region->provider['name']); + $info .= ' ' . \sprintf('(%s)', $region->provider['name']); } if (!empty($region->environmental_impact['carbon_intensity'])) { $format = $green ? ' [%d gC02eq/kWh]' : ' [%d gC02eq/kWh]'; @@ -538,105 +539,95 @@ private function regionInfo(Region $region) * * @return Field[] */ - protected function getFields(SetupOptions $setupOptions = null) + protected function getFields(?SetupOptions $setupOptions = null): array { return [ - 'title' => new Field('Project title', [ - 'optionName' => 'title', - 'description' => 'The initial project title', - 'questionLine' => '', - 'default' => 'Untitled Project', - ]), - 'region' => new OptionsField('Region', [ - 'optionName' => 'region', - 'description' => trim("The region where the project will be hosted.\n" . $this->config()->getWithDefault('messages.region_discount', '')), - 'optionsCallback' => function () use ($setupOptions) { - return $this->getAvailableRegions($setupOptions); - }, - 'allowOther' => true, - ]), - 'plan' => new OptionsField('Plan', [ - 'optionName' => 'plan', - 'description' => 'The subscription plan', - - // The field starts with an empty list of plans. Then when it is - // initialized during "resolveOptions", replace the list of plans - // and set a default if possible. If the organization setup options - // have been supplied ($setupOptions is not null) then that plans - // list will be used. - 'optionsCallback' => function () use ($setupOptions) { - return $this->getAvailablePlans($setupOptions); - }, - 'defaultCallback' => function () use ($setupOptions) { - return $this->getDefaultPlan($this->getAvailablePlans($setupOptions)); - }, - - 'allowOther' => true, - 'avoidQuestion' => true, - ]), - 'environments' => new Field('Environments', [ - 'optionName' => 'environments', - 'description' => 'The number of environments', - 'default' => 3, - 'validator' => function ($value) { - return is_numeric($value) && $value > 0 && $value < 50; - }, - 'avoidQuestion' => true, - ]), - 'storage' => new Field('Storage', [ - 'description' => 'The amount of storage per environment, in GiB', - 'default' => 5, - 'validator' => function ($value) { - return is_numeric($value) && $value > 0 && $value < 1024; - }, - 'avoidQuestion' => true, - ]), - 'default_branch' => new Field('Default branch', [ - 'description' => 'The default Git branch name for the project (the production environment)', - 'required' => false, - 'default' => 'main', - ]), - 'init_repo' => new UrlField('Initialize repository', [ - 'optionName' => 'init-repo', - 'description' => 'URL of a Git repository to use for initialization. A GitHub path such as "platformsh-templates/nuxtjs" can be used.', - 'required' => false, - 'avoidQuestion' => true, - 'normalizer' => function ($url) { - // Provide GitHub as a default. - if (strpos($url, 'github.com') === 0) { - return 'https://github.com' . substr($url, 10); - } - if (strpos($url, '//') === false && preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#', $url)) { - return 'https://github.com/' . $url; - } - return $url; - }, - 'validator' => function ($url) { - if (strpos($url, 'https://') !== 0 && parse_url($url, PHP_URL_SCHEME) !== 'https') { - return 'The initialize repository URL must start with "https://".'; - } - $response = $this->api()->getExternalHttpClient()->get($url, ['exceptions' => false]); - $code = $response->getStatusCode(); - if ($code >= 400) { - return sprintf('The initialize repository URL "%s" returned status code %d. The repository must be public.', $url, $code); - } - return true; - }, - ]), + 'title' => new Field('Project title', [ + 'optionName' => 'title', + 'description' => 'The initial project title', + 'questionLine' => '', + 'default' => 'Untitled Project', + ]), + 'region' => new OptionsField('Region', [ + 'optionName' => 'region', + 'description' => trim("The region where the project will be hosted.\n" . $this->config->getStr('messages.region_discount')), + 'optionsCallback' => fn() => $this->getAvailableRegions($setupOptions), + 'allowOther' => true, + ]), + 'plan' => new OptionsField('Plan', [ + 'optionName' => 'plan', + 'description' => 'The subscription plan', + + // The field starts with an empty list of plans. Then when it is + // initialized during "resolveOptions", replace the list of plans + // and set a default if possible. If the organization setup options + // have been supplied ($setupOptions is not null) then that plans + // list will be used. + 'optionsCallback' => fn() => $this->getAvailablePlans($setupOptions), + 'defaultCallback' => fn() => $this->getDefaultPlan($this->getAvailablePlans($setupOptions)), + + 'allowOther' => true, + 'avoidQuestion' => true, + ]), + 'environments' => new Field('Environments', [ + 'optionName' => 'environments', + 'description' => 'The number of environments', + 'default' => 3, + 'validator' => fn($value): bool => is_numeric($value) && $value > 0 && $value < 50, + 'avoidQuestion' => true, + ]), + 'storage' => new Field('Storage', [ + 'description' => 'The amount of storage per environment, in GiB', + 'default' => 5, + 'validator' => fn($value): bool => is_numeric($value) && $value > 0 && $value < 1024, + 'avoidQuestion' => true, + ]), + 'default_branch' => new Field('Default branch', [ + 'description' => 'The default Git branch name for the project (the production environment)', + 'required' => false, + 'default' => 'main', + ]), + 'init_repo' => new UrlField('Initialize repository', [ + 'optionName' => 'init-repo', + 'description' => 'URL of a Git repository to use for initialization. A GitHub path such as "platformsh-templates/nuxtjs" can be used.', + 'required' => false, + 'avoidQuestion' => true, + 'normalizer' => function (string $url): string { + // Provide GitHub as a default. + if (str_starts_with($url, 'github.com')) { + return 'https://github.com' . substr($url, 10); + } + if (!str_contains($url, '//') && preg_match('#^[a-z0-9-]+/[a-z0-9-]+$#', $url)) { + return 'https://github.com/' . $url; + } + return $url; + }, + 'validator' => function ($url): string|true { + if (!str_starts_with($url, 'https://') && parse_url($url, PHP_URL_SCHEME) !== 'https') { + return 'The initialize repository URL must start with "https://".'; + } + $response = $this->api->getExternalHttpClient()->get($url, ['exceptions' => false]); + $code = $response->getStatusCode(); + if ($code >= 400) { + return sprintf('The initialize repository URL "%s" returned status code %d. The repository must be public.', $url, $code); + } + return true; + }, + ]), ]; } /** - * Get a numeric option value while ensuring it's a reasonable number. + * Gets a numeric option value while ensuring it's a reasonable number. * - * @param \Symfony\Component\Console\Input\InputInterface $input - * @param string $optionName - * @param int $min - * @param int $max + * @param InputInterface $input + * @param string $optionName + * @param int $min + * @param int $max * * @return float|int */ - private function getTimeOption(InputInterface $input, $optionName, $min = 0, $max = 3600) + private function getTimeOption(InputInterface $input, string $optionName, int $min = 0, int $max = 3600): float|int { $value = $input->getOption($optionName); if ($value <= $min) { diff --git a/src/Command/Project/ProjectCurlCommand.php b/src/Command/Project/ProjectCurlCommand.php index 0dadb83de9..dff1e1ac01 100644 --- a/src/Command/Project/ProjectCurlCommand.php +++ b/src/Command/Project/ProjectCurlCommand.php @@ -1,39 +1,39 @@ setName('project:curl') - ->setDescription("Run an authenticated cURL request on a project's API"); + parent::__construct(); + } + protected function configure(): void + { CurlCli::configureInput($this->getDefinition()); - $this->addProjectOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); $this->addExample('Change the project title', '-X PATCH -d \'{"title": "New title"}\''); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); - - // Initialize the API service so that it gets CommandBase's event listeners - // (allowing for auto login). - $this->api(); - - $url = $this->getSelectedProject()->getUri(); - - /** @var CurlCli $curl */ - $curl = $this->getService('curl_cli'); + $selection = $this->selector->getSelection($input); + $url = $selection->getProject()->getUri(); - return $curl->run($url, $input, $output); + return $this->curlCli->run($url, $input, $output); } } diff --git a/src/Command/Project/ProjectDeleteCommand.php b/src/Command/Project/ProjectDeleteCommand.php index 654a717794..cfa659f94b 100644 --- a/src/Command/Project/ProjectDeleteCommand.php +++ b/src/Command/Project/ProjectDeleteCommand.php @@ -1,68 +1,75 @@ setName('project:delete') - ->setDescription('Delete a project') ->addArgument('project', InputArgument::OPTIONAL, 'The project ID'); - $this->addProjectOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { if ($projectId = $input->getArgument('project')) { if ($input->getOption('project')) { throw new ConsoleInvalidArgumentException( - 'You cannot use both the argument and the --project option' + 'You cannot use both the argument and the --project option', ); } $input->setOption('project', $projectId); } - $this->validateInput($input); + $selection = $this->selector->getSelection($input); - $project = $this->getSelectedProject(); - $subscriptionId = $project->getSubscriptionId(); - $subscription = $this->api()->loadSubscription($subscriptionId, $project); + $project = $selection->getProject(); + $subscriptionId = (string) $project->getSubscriptionId(); + $subscription = $this->api->loadSubscription($subscriptionId, $project); if (!$subscription) { $this->stdErr->writeln('Subscription not found: ' . $subscriptionId . ''); $this->stdErr->writeln('Unable to delete the project.'); return 1; } - // TODO check for a HAL 'delete' link on the subscription? - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); $confirmQuestionLines = [ 'You are about to delete the project:', - ' ' . $this->api()->getProjectLabel($project, 'comment'), + ' ' . $this->api->getProjectLabel($project, 'comment'), '', ' * This action is irreversible.', ' * Your site will no longer be accessible.', ' * All data associated with this project will be deleted, including backups.', ' * You will be charged at the end of the month for any remaining project costs.', '', - 'Are you sure you want to delete this project?' + 'Are you sure you want to delete this project?', ]; - if (!$questionHelper->confirm(implode("\n", $confirmQuestionLines), false)) { + if (!$this->questionHelper->confirm(implode("\n", $confirmQuestionLines), false)) { return 1; } $title = $project->title; if ($input->isInteractive() && strlen($title)) { - $confirmName = $questionHelper->askInput('Type the project title to confirm'); + $confirmName = $this->questionHelper->askInput('Type the project title to confirm'); if ($confirmName !== $title) { $this->stdErr->writeln('Incorrect project title (expected: ' . $title . ')'); return 1; @@ -72,9 +79,8 @@ protected function execute(InputInterface $input, OutputInterface $output) try { $subscription->delete(); } catch (ClientException $e) { - $response = $e->getResponse(); - if ($response !== null && $response->getStatusCode() === 403 && !$this->config()->getWithDefault('api.organizations', false)) { - if ($project->owner !== $this->api()->getMyUserId()) { + if ($e->getResponse()->getStatusCode() === 403 && !$this->config->getBool('api.organizations')) { + if ($project->owner !== $this->api->getMyUserId()) { $this->stdErr->writeln("Only the project's owner can delete it."); return 1; } @@ -82,10 +88,10 @@ protected function execute(InputInterface $input, OutputInterface $output) throw $e; } - $this->api()->clearProjectsCache(); + $this->api->clearProjectsCache(); $this->stdErr->writeln(''); - $this->stdErr->writeln('The project ' . $this->api()->getProjectLabel($project) . ' was deleted.'); + $this->stdErr->writeln('The project ' . $this->api->getProjectLabel($project) . ' was deleted.'); return 0; } } diff --git a/src/Command/Project/ProjectGetCommand.php b/src/Command/Project/ProjectGetCommand.php index fcddd9eca9..86a723b400 100644 --- a/src/Command/Project/ProjectGetCommand.php +++ b/src/Command/Project/ProjectGetCommand.php @@ -1,6 +1,21 @@ setName('project:get') - ->setAliases(['get']) - ->setDescription('Clone a project locally') ->addArgument('project', InputArgument::OPTIONAL, 'The project ID') ->addArgument('directory', InputArgument::OPTIONAL, 'The directory to clone to. Defaults to the project title') ->addOption('environment', 'e', InputOption::VALUE_REQUIRED, "The environment ID to clone. Defaults to the project default, or the first available environment") ->addOption('depth', null, InputOption::VALUE_REQUIRED, 'Create a shallow clone: limit the number of commits in the history'); - if ($this->config()->isCommandEnabled('local:build')) { + if ($this->config->isCommandEnabled('local:build')) { $this->addOption('build', null, InputOption::VALUE_NONE, 'Build the project after cloning'); } - $this->addProjectOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); Ssh::configureInput($this->getDefinition()); $this->addExample('Clone the project "abc123" into the directory "my-project"', 'abc123 my-project'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - /** @var \Platformsh\Cli\Service\Git $git */ - $git = $this->getService('git'); - /** @var \Platformsh\Cli\Local\LocalProject $localProject */ - $localProject = $this->getService('local.project'); - // Validate input options and arguments. $this->validateDepth($input); $this->mergeProjectArgument($input); - $this->validateInput($input, false, true, false); + $selection = $this->selector->getSelection($input, new SelectorConfig(selectDefaultEnv: true, detectCurrentEnv: false)); // Load the main variables we need. - $project = $this->getSelectedProject(); - $environment = $this->getSelectedEnvironment(); - $projectLabel = $this->api()->getProjectLabel($project); + $project = $selection->getProject(); + $environment = $selection->getEnvironment(); + $projectLabel = $this->api->getProjectLabel($project); // If this is being run from inside a Git repository, suggest setting // or switching the remote project. $insideCwd = !$input->getArgument('directory') - || basename($input->getArgument('directory')) === $input->getArgument('directory'); - if ($insideCwd && ($gitRoot = $git->getRoot()) !== false && $input->isInteractive()) { - $oldProjectRoot = $localProject->getProjectRoot($gitRoot); - $oldProjectConfig = $oldProjectRoot ? $localProject->getProjectConfig($oldProjectRoot) : false; - $oldProject = $oldProjectConfig ? $this->api()->getProject($oldProjectConfig['id']) : false; + || basename((string) $input->getArgument('directory')) === $input->getArgument('directory'); + if ($insideCwd && ($gitRoot = $this->git->getRoot()) !== false && $input->isInteractive()) { + $oldProjectRoot = $this->localProject->getProjectRoot($gitRoot); + $oldProjectConfig = $oldProjectRoot ? $this->localProject->getProjectConfig($oldProjectRoot) : false; + $oldProject = $oldProjectConfig ? $this->api->getProject($oldProjectConfig['id']) : false; if ($oldProjectRoot && $oldProject && $oldProject->id === $project->id) { $this->stdErr->writeln(sprintf( 'The project %s is already mapped to the directory: %s', $projectLabel, - $oldProjectRoot + $oldProjectRoot, )); return 0; @@ -73,7 +88,7 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($oldProjectRoot !== false) { $this->stdErr->writeln(sprintf('There is already a project in this directory: %s', $oldProjectRoot)); if ($oldProject) { - $oldProjectLabel = $this->api()->getProjectLabel($oldProject); + $oldProjectLabel = $this->api->getProjectLabel($oldProject); } elseif (isset($oldProjectConfig['id'])) { $oldProjectLabel = '' . $oldProjectConfig['id'] . ''; } else { @@ -88,30 +103,25 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(''); - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - if ($questionHelper->confirm($questionText)) { - return $this->runOtherCommand('project:set-remote', ['project' => $project->id], $output); + if ($this->questionHelper->confirm($questionText)) { + return $this->subCommandRunner->run('project:set-remote', ['project' => $project->id], $output); } return 1; } $projectRoot = $this->chooseDirectory($project, $input); - - /** @var \Platformsh\Cli\Service\Filesystem $fs */ - $fs = $this->getService('fs'); - $projectRootFormatted = $fs->formatPathForDisplay($projectRoot); + $projectRootFormatted = $this->filesystem->formatPathForDisplay($projectRoot); // Prepare to talk to the remote repository. $gitUrl = $project->getGitUrl(); - $git->ensureInstalled(); + $this->git->ensureInstalled(); // First check if the repo actually exists. try { - $repoExists = $git->remoteRefExists($gitUrl, 'refs/heads/' . $environment->id) - || $git->remoteRefExists($gitUrl); + $repoExists = $this->git->remoteRefExists($gitUrl, 'refs/heads/' . $environment->id) + || $this->git->remoteRefExists($gitUrl); } catch (ProcessFailedException $e) { // The ls-remote command failed. $this->stdErr->writeln('Failed to connect to the Git repository: ' . $gitUrl . ''); @@ -136,29 +146,29 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - $this->debug('Initializing the repository'); - $git->init($projectRoot, $project->default_branch, true); + $this->io->debug('Initializing the repository'); + $this->git->init($projectRoot, $project->default_branch, true); - $this->debug('Initializing the project'); - $localProject->mapDirectory($projectRoot, $project); + $this->io->debug('Initializing the project'); + $this->localProject->mapDirectory($projectRoot, $project); - if($git->getCurrentBranch($projectRoot) != $project->default_branch) { - $this->debug('current branch does not match the default_branch, create it.'); - $git->checkOutNew($project->default_branch, null, null, $projectRoot); + if ($this->git->getCurrentBranch($projectRoot) != $project->default_branch) { + $this->io->debug('current branch does not match the default_branch, create it.'); + $this->git->checkOutNew($project->default_branch, null, null, $projectRoot); } $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf( 'Your project has been initialized and connected to %s!', - $this->config()->get('service.name') + $this->config->getStr('service.name'), )); $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf( 'Commit and push to the %s branch of the %s Git remote' . ', and %s will build your project automatically.', $project->default_branch, - $this->config()->get('detection.git_remote_name'), - $this->config()->get('service.name') + $this->config->getStr('detection.git_remote_name'), + $this->config->getStr('service.name'), )); return 0; @@ -170,9 +180,9 @@ protected function execute(InputInterface $input, OutputInterface $output) '--branch', $environment->id, '--origin', - $this->config()->get('detection.git_remote_name'), + $this->config->getStr('detection.git_remote_name'), ]; - if ($this->stdErr->isDecorated() && $this->isTerminal(STDERR)) { + if ($this->stdErr->isDecorated() && $this->io->isTerminal(STDERR)) { $cloneArgs[] = '--progress'; } if ($input->getOption('depth')) { @@ -180,31 +190,30 @@ protected function execute(InputInterface $input, OutputInterface $output) $cloneArgs[] = $input->getOption('depth'); $cloneArgs[] = '--shallow-submodules'; } - $cloned = $git->cloneRepo($gitUrl, $projectRoot, $cloneArgs); + $cloned = $this->git->cloneRepo($gitUrl, $projectRoot, $cloneArgs); if ($cloned === false) { // The clone wasn't successful. Clean up the folders we created // and then bow out with a message. $this->stdErr->writeln('Failed to clone Git repository'); $this->stdErr->writeln(sprintf( 'Please check your SSH credentials or contact %s support', - $this->config()->get('service.name') + $this->config->getStr('service.name'), )); return 1; } - $this->debug('Initializing the project'); - $localProject->mapDirectory($projectRoot, $project); - $this->setProjectRoot($projectRoot); + $this->io->debug('Initializing the project'); + $this->localProject->mapDirectory($projectRoot, $project); - $this->debug('Downloading submodules (if any)'); - $git->updateSubmodules(true, $projectRoot); + $this->io->debug('Downloading submodules (if any)'); + $this->git->updateSubmodules(true, $projectRoot); $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf( 'The project %s was successfully downloaded to: %s', $projectLabel, - $projectRootFormatted + $projectRootFormatted, )); // Return early if there is no code in the repository. @@ -216,7 +225,7 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($this->getApplication()->has('local:drush-aliases') && Drupal::isDrupal($projectRoot)) { $this->stdErr->writeln(''); try { - $this->runOtherCommand('local:drush-aliases'); + $this->subCommandRunner->run('local:drush-aliases'); } catch (DependencyMissingException $e) { $this->stdErr->writeln(sprintf('%s', $e->getMessage())); } @@ -229,11 +238,10 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf( 'Building the project locally for the first time. Run %s build to repeat this.', - $this->config()->get('application.executable') + $this->config->getStr('application.executable'), )); $options = ['no-clean' => true]; - /** @var \Platformsh\Cli\Local\LocalBuild $builder */ - $builder = $this->getService('local.build'); + $builder = $this->localBuild; $success = $builder->build($options, $projectRoot); } @@ -245,8 +253,9 @@ protected function execute(InputInterface $input, OutputInterface $output) * * @return void */ - private function validateDepth(InputInterface $input) { - if ($input->getOption('depth') !== null && !preg_match('/^[0-9]+$/', $input->getOption('depth'))) { + private function validateDepth(InputInterface $input): void + { + if ($input->getOption('depth') !== null && !preg_match('/^[0-9]+$/', (string) $input->getOption('depth'))) { throw new InvalidArgumentException('The --depth value must be an integer.'); } } @@ -256,7 +265,8 @@ private function validateDepth(InputInterface $input) { * * @return void */ - private function mergeProjectArgument(InputInterface $input) { + private function mergeProjectArgument(InputInterface $input): void + { if ($input->getOption('project') && $input->getArgument('project')) { throw new InvalidArgumentException('You cannot use both the --project option and the argument.'); } @@ -271,50 +281,39 @@ private function mergeProjectArgument(InputInterface $input) { * * @return string */ - private function chooseDirectory(Project $project, InputInterface $input) { - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - + private function chooseDirectory(Project $project, InputInterface $input): string + { $directory = $input->getArgument('directory'); if (empty($directory)) { $slugify = new Slugify(); $directory = $project->title ? $slugify->slugify($project->title) : $project->id; - $directory = $questionHelper->askInput('Directory', $directory, [$directory, $project->id]); + $directory = $this->questionHelper->askInput('Directory', $directory, [$directory, $project->id]); } if (file_exists($directory)) { throw new InvalidArgumentException('The destination path already exists: ' . $directory); } - if (!$parent = realpath(dirname($directory))) { - throw new InvalidArgumentException('Directory not found: ' . dirname($directory)); + if (!$parent = realpath(dirname((string) $directory))) { + throw new InvalidArgumentException('Directory not found: ' . dirname((string) $directory)); } - - /** @var \Platformsh\Cli\Local\LocalProject $localProject */ - $localProject = $this->getService('local.project'); - if ($localProject->getProjectRoot($directory) !== false) { + if ($this->localProject->getProjectRoot($directory) !== false) { throw new InvalidArgumentException('A project cannot be cloned inside another project.'); } - return $parent . DIRECTORY_SEPARATOR . basename($directory); + return $parent . DIRECTORY_SEPARATOR . basename((string) $directory); } /** - * Suggest SSH key commands for the user, if the Git connection fails. - * - * @param string $gitUrl - * @param Process $process + * Suggests SSH key commands for the user, if the Git connection fails. */ - protected function suggestSshRemedies($gitUrl, Process $process) + protected function suggestSshRemedies(string $gitUrl, Process $process): void { // Remove the path from the git URI to get the SSH part. $gitSshUri = ''; - if (strpos($gitUrl, ':') !== false) { - list($gitSshUri,) = explode(':', $gitUrl, 2); + if (str_contains($gitUrl, ':')) { + [$gitSshUri, ] = explode(':', $gitUrl, 2); } - - /** @var \Platformsh\Cli\Service\SshDiagnostics $sshDiagnostics */ - $sshDiagnostics = $this->getService('ssh_diagnostics'); - $sshDiagnostics->diagnoseFailure($gitSshUri, $process); + $this->sshDiagnostics->diagnoseFailure($gitSshUri, $process); } } diff --git a/src/Command/Project/ProjectInfoCommand.php b/src/Command/Project/ProjectInfoCommand.php index 50920e5b39..9470bfa235 100644 --- a/src/Command/Project/ProjectInfoCommand.php +++ b/src/Command/Project/ProjectInfoCommand.php @@ -1,47 +1,53 @@ setName('project:info') ->addArgument('property', InputArgument::OPTIONAL, 'The name of the property') ->addArgument('value', InputArgument::OPTIONAL, 'Set a new value for the property') - ->addOption('refresh', null, InputOption::VALUE_NONE, 'Whether to refresh the cache') - ->setDescription('Read or set properties for a project'); + ->addOption('refresh', null, InputOption::VALUE_NONE, 'Whether to refresh the cache'); PropertyFormatter::configureInput($this->getDefinition()); Table::configureInput($this->getDefinition()); - $this->addProjectOption()->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); $this->addExample('Read all project properties') ->addExample("Show the project's Git URL", 'git') ->addExample("Change the project's title", 'title "My project"'); $this->setHiddenAliases(['project:metadata']); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); + $selection = $this->selector->getSelection($input); - $project = $this->getSelectedProject(); - $this->formatter = $this->getService('property_formatter'); + $project = $selection->getProject(); if ($input->getOption('refresh')) { $project->refresh(); @@ -63,7 +69,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $value = $input->getArgument('value'); if ($value !== null) { - return $this->setProperty($property, $value, $project, !$this->shouldWait($input)); + return $this->setProperty($property, $value, $project, !$this->activityMonitor->shouldWait($input)); } switch ($property) { @@ -79,43 +85,33 @@ protected function execute(InputInterface $input, OutputInterface $output) throw new \InvalidArgumentException('Property not found: ' . $property); default: - $value = $this->api()->getNestedProperty($project, $property); + $value = $this->api->getNestedProperty($project, $property); } - $output->writeln($this->formatter->format($value, $property)); + $output->writeln($this->propertyFormatter->format($value, $property)); return 0; } /** - * @param array $properties + * @param array $properties * * @return int */ - protected function listProperties(array $properties) + protected function listProperties(array $properties): int { $headings = []; $values = []; foreach ($properties as $key => $value) { $headings[] = new AdaptiveTableCell($key, ['wrap' => false]); - $values[] = $this->formatter->format($value, $key); + $values[] = $this->propertyFormatter->format($value, $key); } - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - $table->renderSimple($values, $headings); + $this->table->renderSimple($values, $headings); return 0; } - /** - * @param string $property - * @param string $value - * @param Project $project - * @param bool $noWait - * - * @return int - */ - protected function setProperty($property, $value, Project $project, $noWait) + protected function setProperty(string $property, string $value, Project $project, bool $noWait): int { $type = $this->getType($property); if (!$type) { @@ -129,7 +125,7 @@ protected function setProperty($property, $value, Project $project, $noWait) $currentValue = $project->getProperty($property); if ($currentValue === $value) { $this->stdErr->writeln( - "Property $property already set as: " . $this->formatter->format($value, $property) + "Property $property already set as: " . $this->propertyFormatter->format($value, $property), ); return 0; @@ -140,29 +136,24 @@ protected function setProperty($property, $value, Project $project, $noWait) $this->stdErr->writeln(sprintf( 'Property %s set to: %s', $property, - $this->formatter->format($value, $property) + $this->propertyFormatter->format($value, $property), )); - $this->api()->clearProjectsCache(); + $this->api->clearProjectsCache(); $success = true; if (!$noWait) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); - $success = $activityMonitor->waitMultiple($result->getActivities(), $this->getSelectedProject()); + $activityMonitor = $this->activityMonitor; + $success = $activityMonitor->waitMultiple($result->getActivities(), $project); } return $success ? 0 : 1; } /** - * Get the type of a writable property. - * - * @param string $property - * - * @return string|false + * Gets the type of a writable property. */ - protected function getType($property) + protected function getType(string $property): string|false { $writableProperties = [ 'title' => 'string', @@ -171,6 +162,6 @@ protected function getType($property) 'default_branch' => 'string', ]; - return isset($writableProperties[$property]) ? $writableProperties[$property] : false; + return $writableProperties[$property] ?? false; } } diff --git a/src/Command/Project/ProjectListCommand.php b/src/Command/Project/ProjectListCommand.php index b174d0c42d..954b318db2 100644 --- a/src/Command/Project/ProjectListCommand.php +++ b/src/Command/Project/ProjectListCommand.php @@ -1,22 +1,31 @@ */ + private array $tableHeader = [ 'id' => 'ID', 'title' => 'Title', 'region' => 'Region', @@ -26,19 +35,27 @@ class ProjectListCommand extends CommandBase 'status' => 'Status', 'created_at' => 'Created', ]; - private $defaultColumns = ['id', 'title', 'region']; + /** @var string[] */ + private array $defaultColumns = ['id', 'title', 'region']; + + public function __construct( + private readonly Api $api, + private readonly Config $config, + private readonly PropertyFormatter $propertyFormatter, + private readonly Io $io, + private readonly Table $table, + ) { + parent::__construct(); + } - protected function configure() + protected function configure(): void { - $organizationsEnabled = $this->config()->getWithDefault('api.organizations', false); + $organizationsEnabled = $this->config->getBool('api.organizations'); $this->defaultColumns = ['id', 'title', 'region']; if ($organizationsEnabled) { $this->defaultColumns[] = 'organization_name'; } $this - ->setName('project:list') - ->setAliases(['projects', 'pro']) - ->setDescription('Get a list of all active projects') ->addOption('pipe', null, InputOption::VALUE_NONE, 'Output a simple list of project IDs. Disables pagination.') ->addOption('region', null, InputOption::VALUE_REQUIRED, 'Filter by region (exact match)') ->addHiddenOption('host', null, InputOption::VALUE_REQUIRED, 'Deprecated: replaced by --region') @@ -58,16 +75,16 @@ protected function configure() PropertyFormatter::configureInput($this->getDefinition()); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->warnAboutDeprecatedOptions(['host'], 'The option --host is deprecated and replaced by --region. It will be removed in a future version.'); + $this->io->warnAboutDeprecatedOptions(['host'], 'The option --host is deprecated and replaced by --region. It will be removed in a future version.'); $refresh = $input->hasOption('refresh') && $input->getOption('refresh'); // Fetch the list of projects. $progress = new ProgressMessage($output); $progress->showIfOutputDecorated('Loading projects...'); - $projects = $this->api()->getMyProjects($refresh ? true : null); + $projects = $this->api->getMyProjects($refresh ? true : null); $progress->done(); // Filter the list of projects. @@ -105,13 +122,13 @@ protected function execute(InputInterface $input, OutputInterface $output) return 0; } $this->stdErr->writeln( - 'You do not have any ' . $this->config()->get('service.name') . ' projects yet.' + 'You do not have any ' . $this->config->getStr('service.name') . ' projects yet.', ); - if ($this->config()->isCommandEnabled('project:create')) { + if ($this->config->isCommandEnabled('project:create')) { $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf( 'To create a new project, run: %s create', - $this->config()->get('application.executable') + $this->config->getStr('application.executable'), )); } @@ -120,18 +137,18 @@ protected function execute(InputInterface $input, OutputInterface $output) // Display a simple list of project IDs, if --pipe is used. if ($input->getOption('pipe')) { - $output->writeln(\array_map(function (BasicProjectInfo $info) { return $info->id; }, $projects)); + $output->writeln(\array_map(fn(BasicProjectInfo $info): string => $info->id, $projects)); return 0; } // Paginate the list. - if (!$this->config()->getWithDefault('pagination.enabled', true) && $input->getOption('page') === null) { + if (!$this->config->getBool('pagination.enabled') && $input->getOption('page') === null) { $itemsPerPage = 0; } elseif ($input->getOption('count') !== null) { - $itemsPerPage = (int)$input->getOption('count'); + $itemsPerPage = (int) $input->getOption('count'); } else { - $itemsPerPage = (int) $this->config()->getWithDefault('pagination.count', 20); + $itemsPerPage = $this->config->getInt('pagination.count'); } $page = (new Pager())->page($projects, (int) $input->getOption('page') ?: 1, $itemsPerPage); /** @var BasicProjectInfo[] $projects */ @@ -140,16 +157,10 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(\sprintf('No projects found on this page (%s)', $page->displayInfo())); return 1; } + $machineReadable = $this->table->formatIsMachineReadable(); - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - $machineReadable = $table->formatIsMachineReadable(); - - $table->replaceDeprecatedColumns(['host' => 'region'], $input, $output); - $table->removeDeprecatedColumns(['url', 'ui_url', 'endpoint', 'region_label'], '[deprecated]', $input, $output); - - /** @var PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); + $this->table->replaceDeprecatedColumns(['host' => 'region'], $input, $output); + $this->table->removeDeprecatedColumns(['url', 'ui_url', 'endpoint', 'region_label'], '[deprecated]', $input, $output); $rows = []; foreach ($projects as $projectInfo) { @@ -159,7 +170,7 @@ protected function execute(InputInterface $input, OutputInterface $output) if (!$machineReadable && $projectInfo->status === Subscription::STATUS_SUSPENDED) { $title = sprintf( '%s (suspended)', - $title + $title, ); } @@ -173,7 +184,7 @@ protected function execute(InputInterface $input, OutputInterface $output) 'organization_name' => $orgInfo ? $orgInfo->name : '', 'organization_label' => $orgInfo ? $orgInfo->label : '', 'status' => $projectInfo->status, - 'created_at' => $formatter->format($projectInfo->created_at, 'created_at'), + 'created_at' => $this->propertyFormatter->format($projectInfo->created_at, 'created_at'), '[deprecated]' => '', ]; } @@ -183,7 +194,7 @@ protected function execute(InputInterface $input, OutputInterface $output) // Display a simple table (and no messages) if the --format is // machine-readable (e.g. csv or tsv). if ($machineReadable) { - $table->render($rows, $this->tableHeader, $this->defaultColumns); + $this->table->render($rows, $this->tableHeader, $this->defaultColumns); return 0; } @@ -197,9 +208,9 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(':'); } - $table->render($rows, $this->tableHeader, $this->defaultColumns); + $this->table->render($rows, $this->tableHeader, $this->defaultColumns); - $executable = $this->config()->get('application.executable'); + $executable = $this->config->getStr('application.executable'); if ($page->pageCount > 1 && $itemsPerPage !== 0) { // State the command name explicitly here, as it may be displaying @@ -217,31 +228,27 @@ protected function execute(InputInterface $input, OutputInterface $output) } /** - * Filter the list of projects. + * Filters the list of projects. * - * @param BasicProjectInfo[] &$projects + * @param BasicProjectInfo[] &$projects * @param array $filters */ - protected function filterProjects(array &$projects, array $filters) + protected function filterProjects(array &$projects, array $filters): void { foreach ($filters as $filter => $value) { switch ($filter) { case 'region': - $projects = array_filter($projects, function (BasicProjectInfo $project) use ($value) { - return strcasecmp($value, $project->region) === 0; - }); + $projects = array_filter($projects, fn(BasicProjectInfo $project): bool => strcasecmp((string) $value, (string) $project->region) === 0); break; case 'title': - $projects = array_filter($projects, function (BasicProjectInfo $project) use ($value) { - return stripos($project->title, $value) !== false; - }); + $projects = array_filter($projects, fn(BasicProjectInfo $project): bool => stripos($project->title, (string) $value) !== false); break; case 'my': - $ownerId = $this->api()->getMyUserId(); - $organizationsEnabled = $this->config()->getWithDefault('api.organizations', false); - $projects = array_filter($projects, function (BasicProjectInfo $project) use ($ownerId, $organizationsEnabled) { + $ownerId = $this->api->getMyUserId(); + $organizationsEnabled = $this->config->getBool('api.organizations'); + $projects = array_filter($projects, function (BasicProjectInfo $project) use ($ownerId, $organizationsEnabled): bool { if ($organizationsEnabled && $project->organization_ref !== null) { return $project->organization_ref->owner_id === $ownerId; } @@ -251,8 +258,8 @@ protected function filterProjects(array &$projects, array $filters) case 'org': // The value is an organization name or ID. - $isID = \preg_match('#^[\dA-HJKMNP-TV-Z]{26}$#', $value) === 1; - $projects = \array_filter($projects, function (BasicProjectInfo $info) use ($value, $isID) { + $isID = \preg_match('#^[\dA-HJKMNP-TV-Z]{26}$#', (string) $value) === 1; + $projects = \array_filter($projects, function (BasicProjectInfo $info) use ($value, $isID): bool { if (!empty($info->organization_ref->id)) { return $isID ? $info->organization_ref->id === $value : $info->organization_ref->name === $value; } diff --git a/src/Command/Project/ProjectSetRemoteCommand.php b/src/Command/Project/ProjectSetRemoteCommand.php index e95d552e84..8f4a3c1837 100644 --- a/src/Command/Project/ProjectSetRemoteCommand.php +++ b/src/Command/Project/ProjectSetRemoteCommand.php @@ -1,26 +1,42 @@ setName('project:set-remote') - ->setAliases(['set-remote']) - ->setDescription('Set the remote project for the current Git repository') ->addArgument('project', InputArgument::OPTIONAL, 'The project ID'); + $this->addCompleter($this->selector); $this->addExample('Set the remote project for this repository to "abcdef123456"', 'abcdef123456'); $this->addExample('Unset the remote project for this repository', '-'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $projectId = $input->getArgument('project'); $unset = false; @@ -30,42 +46,35 @@ protected function execute(InputInterface $input, OutputInterface $output) } if ($projectId) { - /** @var \Platformsh\Cli\Service\Identifier $identifier */ - $identifier = $this->getService('identifier'); + $identifier = $this->identifier; $result = $identifier->identify($projectId); $projectId = $result['projectId']; } - - /** @var \Platformsh\Cli\Service\Git $git */ - $git = $this->getService('git'); - $root = $git->getRoot(getcwd()); + $cwd = getcwd(); + if (!$cwd) { + throw new \RuntimeException('Failed to find current working directory'); + } + $root = $this->git->getRoot($cwd); if ($root === false) { $this->stdErr->writeln( - 'No Git repository found. Use git init to create a repository.' + 'No Git repository found. Use git init to create a repository.', ); return 1; } - $this->debug('Git repository found: ' . $root); - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - /** @var \Platformsh\Cli\Local\LocalProject $localProject */ - $localProject = $this->getService('local.project'); - /** @var \Platformsh\Cli\Service\Filesystem $fs */ - $fs = $this->getService('fs'); + $this->io->debug('Git repository found: ' . $root); if ($unset) { - $configFilename = $root . DIRECTORY_SEPARATOR . $this->config()->get('local.project_config'); + $configFilename = $root . DIRECTORY_SEPARATOR . $this->config->getStr('local.project_config'); if (!\file_exists($configFilename)) { $configFilename = null; } - $git->ensureInstalled(); + $this->git->ensureInstalled(); $gitRemotes = []; - foreach ([$this->config()->get('detection.git_remote_name'), 'origin'] as $remote) { - $url = $git->getConfig(sprintf('remote.%s.url', $remote)); - if (\is_string($url) && $localProject->parseGitUrl($url) !== false) { + foreach ([$this->config->getStr('detection.git_remote_name'), 'origin'] as $remote) { + $url = $this->git->getConfig(sprintf('remote.%s.url', $remote)); + if (\is_string($url) && $this->localProject->parseGitUrl($url) !== false) { $gitRemotes[$remote] = $url; } } @@ -76,20 +85,20 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln('Unsetting the remote project for this repository'); $this->stdErr->writeln(''); if ($configFilename) { - $this->stdErr->writeln(sprintf('This config file will be deleted: %s', $fs->formatPathForDisplay($configFilename))); + $this->stdErr->writeln(sprintf('This config file will be deleted: %s', $this->filesystem->formatPathForDisplay($configFilename))); } if ($gitRemotes) { $this->stdErr->writeln(sprintf('These Git remote(s) will be deleted: %s', \implode(', ', \array_keys($gitRemotes)))); } $this->stdErr->writeln(''); - if (!$questionHelper->confirm('Are you sure?')) { + if (!$this->questionHelper->confirm('Are you sure?')) { return 1; } foreach (array_keys($gitRemotes) as $gitRemote) { - $git->execute( + $this->git->execute( ['remote', 'rm', $gitRemote], $root, - true + true, ); } if ($configFilename) { @@ -99,64 +108,66 @@ protected function execute(InputInterface $input, OutputInterface $output) return 0; } - $currentProject = $this->getCurrentProject(true); + $currentProject = $this->selector->getCurrentProject(true); if ($currentProject) { $this->stdErr->writeln(sprintf( 'This repository is already linked to the remote project: %s', - $this->api()->getProjectLabel($currentProject, 'comment') + $this->api->getProjectLabel($currentProject, 'comment'), )); - if (!$questionHelper->confirm('Are you sure you want to change it?')) { + if (!$this->questionHelper->confirm('Are you sure you want to change it?')) { return 1; } $this->stdErr->writeln(''); - $this->chooseProjectText = 'Enter a number to choose another project:'; - $this->enterProjectText = 'Enter the ID of another project'; + $selectorConfig = new SelectorConfig(chooseProjectText: 'Enter a number to choose another project:', enterProjectText: 'Enter the ID of another project'); + } else { + $selectorConfig = null; } $asking = $projectId === null; - $project = $this->selectProject($projectId, null, false); + $selection = $this->selector->getSelection($input, $selectorConfig); if ($asking) { $this->stdErr->writeln(''); } + $project = $selection->getProject(); if ($currentProject && $currentProject->id === $project->id) { $this->stdErr->writeln(sprintf( 'The remote project for this repository is already set as: %s', - $this->api()->getProjectLabel($currentProject) + $this->api->getProjectLabel($currentProject), )); return 0; } elseif ($currentProject) { $this->stdErr->writeln(sprintf( 'Changing the remote project for this repository from %s to %s', - $this->api()->getProjectLabel($currentProject), - $this->api()->getProjectLabel($project) + $this->api->getProjectLabel($currentProject), + $this->api->getProjectLabel($project), )); $this->stdErr->writeln(''); } else { $this->stdErr->writeln(sprintf( 'Setting the remote project for this repository to: %s', - $this->api()->getProjectLabel($project) + $this->api->getProjectLabel($project), )); $this->stdErr->writeln(''); } - $localProject->mapDirectory($root, $project); + $this->localProject->mapDirectory($root, $project); $this->stdErr->writeln(sprintf( 'The remote project for this repository is now set to: %s', - $this->api()->getProjectLabel($project) + $this->api->getProjectLabel($project), )); if ($input->isInteractive()) { - $currentBranch = $git->getCurrentBranch($root); - $currentEnvironment = $currentBranch ? $this->api()->getEnvironment($currentBranch, $project) : false; + $currentBranch = $this->git->getCurrentBranch($root); + $currentEnvironment = $currentBranch ? $this->api->getEnvironment($currentBranch, $project) : false; if ($currentBranch !== false && $currentEnvironment && $currentEnvironment->has_code) { - $headSha = $git->execute(['rev-parse', '--verify', 'HEAD'], $root); + $headSha = $this->git->execute(['rev-parse', '--verify', 'HEAD'], $root); if ($currentEnvironment->head_commit === $headSha) { $this->stdErr->writeln(sprintf("\nThe local branch %s is up to date.", $currentBranch)); - } elseif ($questionHelper->confirm("\nDo you want to pull code from the project?")) { - $success = $git->pull($project->getGitUrl(), $currentEnvironment->id, $root, false); + } elseif ($this->questionHelper->confirm("\nDo you want to pull code from the project?")) { + $success = $this->git->pull($project->getGitUrl(), $currentEnvironment->id, $root, false); return $success ? 0 : 1; } diff --git a/src/Command/Project/Variable/ProjectVariableDeleteCommand.php b/src/Command/Project/Variable/ProjectVariableDeleteCommand.php index d455491a88..489667d35d 100644 --- a/src/Command/Project/Variable/ProjectVariableDeleteCommand.php +++ b/src/Command/Project/Variable/ProjectVariableDeleteCommand.php @@ -1,7 +1,14 @@ setName('project:variable:delete') - ->addArgument('name', InputArgument::REQUIRED, 'The variable name') - ->setDescription('Delete a variable from a project'); + ->addArgument('name', InputArgument::REQUIRED, 'The variable name'); $this->setHelp( 'This command is deprecated and will be removed in a future version.' - . "\nInstead, use: variable:delete --level project [variable]" + . "\nInstead, use: variable:delete --level project [variable]", ); - $this->addProjectOption() - ->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); - - return $this->runOtherCommand('variable:delete', [ - 'name' => $input->getArgument('name'), - '--level' => 'project', - '--project' => $this->getSelectedProject()->id, - ] + array_filter([ - '--wait' => $input->getOption('wait'), - '--no-wait' => $input->getOption('no-wait'), - ])); + $selection = $this->selector->getSelection($input); + + return $this->subCommandRunner->run('variable:delete', [ + 'name' => $input->getArgument('name'), + '--level' => 'project', + '--project' => $selection->getProject()->id, + ] + array_filter([ + '--wait' => $input->getOption('wait'), + '--no-wait' => $input->getOption('no-wait'), + ])); } } diff --git a/src/Command/Project/Variable/ProjectVariableGetCommand.php b/src/Command/Project/Variable/ProjectVariableGetCommand.php index 2b450f90f1..417e1eb0ca 100644 --- a/src/Command/Project/Variable/ProjectVariableGetCommand.php +++ b/src/Command/Project/Variable/ProjectVariableGetCommand.php @@ -1,8 +1,14 @@ setName('project:variable:get') - ->setAliases(['project-variables', 'pvget']) ->addArgument('name', InputArgument::OPTIONAL, 'The name of the variable') - ->addOption('pipe', null, InputOption::VALUE_NONE, 'Output the full variable value only (a "name" must be specified)') - ->setDescription('View variable(s) for a project'); + ->addOption('pipe', null, InputOption::VALUE_NONE, 'Output the full variable value only (a "name" must be specified)'); $this->setHelp( 'This command is deprecated and will be removed in a future version.' - . "\nInstead, use variable:list and variable:get" + . "\nInstead, use variable:list and variable:get", ); Table::configureInput($this->getDefinition()); - $this->addProjectOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); $this->setHiddenAliases(['project:variable:list']); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); + $selection = $this->selector->getSelection($input); - return $this->runOtherCommand('variable:get', [ + return $this->subCommandRunner->run('variable:get', [ 'name' => $input->getArgument('name'), '--level' => 'project', - '--project' => $this->getSelectedProject()->id, - ] + array_filter([ - '--format' => $input->getOption('format'), - '--pipe' => $input->getOption('pipe'), - ])); + '--project' => $selection->getProject()->id, + ] + array_filter([ + '--format' => $input->getOption('format'), + '--pipe' => $input->getOption('pipe'), + ])); } } diff --git a/src/Command/Project/Variable/ProjectVariableSetCommand.php b/src/Command/Project/Variable/ProjectVariableSetCommand.php index 900a7419eb..aeed2e85d9 100644 --- a/src/Command/Project/Variable/ProjectVariableSetCommand.php +++ b/src/Command/Project/Variable/ProjectVariableSetCommand.php @@ -1,7 +1,14 @@ setName('project:variable:set') - ->setAliases(['pvset']) ->addArgument('name', InputArgument::REQUIRED, 'The variable name') ->addArgument('value', InputArgument::REQUIRED, 'The variable value') ->addOption('json', null, InputOption::VALUE_NONE, 'Mark the value as JSON') ->addOption('no-visible-build', null, InputOption::VALUE_NONE, 'Do not expose this variable at build time') - ->addOption('no-visible-runtime', null, InputOption::VALUE_NONE, 'Do not expose this variable at runtime') - ->setDescription('Set a variable for a project'); + ->addOption('no-visible-runtime', null, InputOption::VALUE_NONE, 'Do not expose this variable at runtime'); $this->setHelp( 'This command is deprecated and will be removed in a future version.' - . "\nInstead, use variable:create and variable:update" + . "\nInstead, use variable:create and variable:update", ); - $this->addProjectOption() - ->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); + $selection = $this->selector->getSelection($input); $variableName = $input->getArgument('name'); $variableValue = $input->getArgument('value'); @@ -53,7 +61,7 @@ protected function execute(InputInterface $input, OutputInterface $output) // Check whether the variable already exists. If there is no change, // quit early. - $existing = $this->getSelectedProject() + $existing = $selection->getProject() ->getVariable($variableName); if ($existing && $existing->value === $variableValue && $existing->is_json == $json) { $this->stdErr->writeln("Variable $variableName already set as: $variableValue"); @@ -62,22 +70,17 @@ protected function execute(InputInterface $input, OutputInterface $output) } // Set the variable to a new value. - $this->getSelectedProject() + $selection->getProject() ->setVariable($variableName, $variableValue, $json, !$supressBuild, !$supressRuntime); $this->stdErr->writeln("Variable $variableName set to: $variableValue"); - $this->redeployWarning(); + $this->api->redeployWarning(); return 0; } - /** - * @param $string - * - * @return bool - */ - protected function validateJson($string) + protected function validateJson(string $string): bool { if ($string === 'null') { return true; diff --git a/src/Command/Repo/CatCommand.php b/src/Command/Repo/CatCommand.php index f21d7257d3..81d9c8ad1c 100644 --- a/src/Command/Repo/CatCommand.php +++ b/src/Command/Repo/CatCommand.php @@ -1,38 +1,57 @@ setName('repo:cat') // 🐱 - ->setDescription('Read a file in the project repository') ->addArgument('path', InputArgument::REQUIRED, 'The path to the file') ->addCommitOption(); - $this->addProjectOption(); - $this->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); $this->addExample( 'Read the services configuration file', - $this->config()->get('service.project_config_dir') . '/services.yaml' + $this->config->getStr('service.project_config_dir') . '/services.yaml', ); } /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input, false, true); + $selection = $this->selector->getSelection($input, new SelectorConfig(selectDefaultEnv: true)); - return $this->cat($this->getSelectedEnvironment(), $input, $output); + try { + return $this->cat($input->getArgument('path'), $selection->getEnvironment(), $input, $output); + } catch (GitObjectTypeException $e) { + $this->stdErr->writeln(sprintf( + '%s: %s', + $e->getMessage(), + $e->getPath(), + )); + $this->stdErr->writeln(sprintf('To list directory contents, run: %s repo:ls [path]', $this->config->getStr('application.executable'))); + return 3; + } } } diff --git a/src/Command/Repo/LsCommand.php b/src/Command/Repo/LsCommand.php index 36b9e573bc..f046746c5d 100644 --- a/src/Command/Repo/LsCommand.php +++ b/src/Command/Repo/LsCommand.php @@ -1,39 +1,57 @@ setName('repo:ls') - ->setDescription('List files in the project repository') ->addArgument('path', InputArgument::OPTIONAL, 'The path to a subdirectory') ->addOption('directories', 'd', InputOption::VALUE_NONE, 'Show directories only') ->addOption('files', 'f', InputOption::VALUE_NONE, 'Show files only') ->addOption('git-style', null, InputOption::VALUE_NONE, 'Style output similar to "git ls-tree"') ->addCommitOption(); - $this->addProjectOption(); - $this->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); } /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input, false, true); + $selection = $this->selector->getSelection($input, new SelectorConfig(selectDefaultEnv: true)); - return $this->ls($this->getSelectedEnvironment(), $input, $output); + try { + return $this->ls($input->getArgument('path') ?: '/', $selection->getEnvironment(), $input, $output); + } catch (GitObjectTypeException $e) { + $this->stdErr->writeln(sprintf( + '%s: %s', + $e->getMessage(), + $e->getPath(), + )); + $this->stdErr->writeln(sprintf('To read a file, run: %s repo:cat [path]', $this->config->getStr('application.executable'))); + return 3; + } } } diff --git a/src/Command/Repo/ReadCommand.php b/src/Command/Repo/ReadCommand.php index f7123f9f66..135c892ff8 100644 --- a/src/Command/Repo/ReadCommand.php +++ b/src/Command/Repo/ReadCommand.php @@ -1,42 +1,46 @@ setName('repo:read') - ->setAliases(['read']) - ->setDescription('Read a directory or file in the project repository') ->addArgument('path', InputArgument::OPTIONAL, 'The path to the directory or file') ->addCommitOption(); - $this->addProjectOption(); - $this->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); } /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input, false, true); - $environment = $this->getSelectedEnvironment(); + $selection = $this->selector->getSelection($input, new SelectorConfig(selectDefaultEnv: true)); + $environment = $selection->getEnvironment(); - $path = $input->getArgument('path'); - /** @var GitDataApi $gitData */ - $gitData = $this->getService('git_data_api'); - $object = $gitData->getObject($path, $environment, $input->getOption('commit')); + $path = $input->getArgument('path') ?: '/'; + $object = $this->gitDataApi->getObject($path, $environment, $input->getOption('commit')); if ($object === false) { $this->stdErr->writeln(sprintf('File or directory not found: %s', $path)); @@ -44,7 +48,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } return $object instanceof Tree - ? $this->ls($environment, $input, $output) - : $this->cat($environment, $input, $output); + ? $this->ls($path, $environment, $input, $output) + : $this->cat($path, $environment, $input, $output); } } diff --git a/src/Command/Repo/RepoCommandBase.php b/src/Command/Repo/RepoCommandBase.php index 6353accadd..37ee41b49f 100644 --- a/src/Command/Repo/RepoCommandBase.php +++ b/src/Command/Repo/RepoCommandBase.php @@ -1,10 +1,12 @@ gitDataApi = $gitDataApi; + } + /** * Adds the --commit (-c) command option. */ - protected function addCommitOption() + protected function addCommitOption(): static { $this->addOption('commit', 'c', InputOption::VALUE_REQUIRED, 'The commit SHA. ' . GitDataApi::COMMIT_SYNTAX_HELP); return $this; @@ -24,29 +34,16 @@ protected function addCommitOption() /** * Reads a file in a repository using the Git Data API. * + * @param string $path * @param Environment $environment * @param InputInterface $input * @param OutputInterface $output * * @return int */ - protected function cat(Environment $environment, InputInterface $input, OutputInterface $output) + protected function cat(string $path, Environment $environment, InputInterface $input, OutputInterface $output): int { - $path = $input->getArgument('path'); - try { - /** @var \Platformsh\Cli\Service\GitDataApi $gitData */ - $gitData = $this->getService('git_data_api'); - $content = $gitData->readFile($path, $environment, $input->getOption('commit')); - } catch (GitObjectTypeException $e) { - $this->stdErr->writeln(sprintf( - '%s: %s', - $e->getMessage(), - $e->getPath() - )); - $this->stdErr->writeln(sprintf('To list directory contents, run: %s repo:ls [path]', $this->config()->get('application.executable'))); - - return 3; - } + $content = $this->gitDataApi->readFile($path, $environment, $input->getOption('commit')); if ($content === false) { $this->stdErr->writeln(sprintf('File not found: %s', $path)); @@ -61,28 +58,16 @@ protected function cat(Environment $environment, InputInterface $input, OutputIn /** * Lists files in a tree using the Git Data API. * + * @param string $path * @param Environment $environment * @param InputInterface $input * @param OutputInterface $output * * @return int */ - protected function ls(Environment $environment, InputInterface $input, OutputInterface $output) + protected function ls(string $path, Environment $environment, InputInterface $input, OutputInterface $output): int { - try { - /** @var \Platformsh\Cli\Service\GitDataApi $gitData */ - $gitData = $this->getService('git_data_api'); - $tree = $gitData->getTree($environment, $input->getArgument('path'), $input->getOption('commit')); - } catch (GitObjectTypeException $e) { - $this->stdErr->writeln(sprintf( - '%s: %s', - $e->getMessage(), - $e->getPath() - )); - $this->stdErr->writeln(sprintf('To read a file, run: %s repo:cat [path]', $this->config()->get('application.executable'))); - - return 3; - } + $tree = $this->gitDataApi->getTree($environment, $path, $input->getOption('commit')); if (!$tree) { $this->stdErr->writeln(sprintf('Directory not found: %s', $input->getArgument('path'))); @@ -92,13 +77,9 @@ protected function ls(Environment $environment, InputInterface $input, OutputInt $treeObjects = $tree->tree; if ($input->hasOption('files') && $input->hasOption('directories')) { if ($input->getOption('files') && !$input->getOption('directories')) { - $treeObjects = array_filter($treeObjects, function (array $treeObject) { - return $treeObject['type'] === 'blob'; - }); + $treeObjects = array_filter($treeObjects, fn(array $treeObject): bool => $treeObject['type'] === 'blob'); } elseif ($input->getOption('directories') && !$input->getOption('files')) { - $treeObjects = array_filter($treeObjects, function (array $treeObject) { - return $treeObject['type'] === 'tree'; - }); + $treeObjects = array_filter($treeObjects, fn(array $treeObject): bool => $treeObject['type'] === 'tree'); } } @@ -111,7 +92,7 @@ protected function ls(Environment $environment, InputInterface $input, OutputInt $object['mode'], $object['type'], $object['sha'], - $object['path'] + $object['path'], )); } else { $format = '%s'; diff --git a/src/Command/Resources/Build/BuildResourcesGetCommand.php b/src/Command/Resources/Build/BuildResourcesGetCommand.php index eda150bc53..494488dde8 100644 --- a/src/Command/Resources/Build/BuildResourcesGetCommand.php +++ b/src/Command/Resources/Build/BuildResourcesGetCommand.php @@ -1,50 +1,58 @@ */ + protected array $tableHeader = [ 'cpu' => 'CPU', 'memory' => 'Memory (MB)', ]; - protected function configure() + public function __construct(private readonly Api $api, private readonly Config $config, private readonly Selector $selector, private readonly Table $table) { - $this->setName('resources:build:get') - ->setAliases(['build-resources:get', 'build-resources']) - ->setDescription('View the build resources of a project') - ->addProjectOption(); + parent::__construct(); + } + + protected function configure(): void + { + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); Table::configureInput($this->getDefinition(), $this->tableHeader); - if ($this->config()->has('service.resources_help_url')) { - $this->setHelp('For more information on managing resources, see: ' . $this->config()->get('service.resources_help_url') . ''); + if ($this->config->has('service.resources_help_url')) { + $this->setHelp('For more information on managing resources, see: ' . $this->config->getStr('service.resources_help_url') . ''); } } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); - if (!$this->api()->supportsSizingApi($this->getSelectedProject())) { - $this->stdErr->writeln(sprintf('The flexible resources API is not enabled for the project %s.', $this->api()->getProjectLabel($this->getSelectedProject(), 'comment'))); + $selection = $this->selector->getSelection($input); + if (!$this->api->supportsSizingApi($selection->getProject())) { + $this->stdErr->writeln(sprintf('The flexible resources API is not enabled for the project %s.', $this->api->getProjectLabel($selection->getProject(), 'comment'))); return 1; } - $project = $this->getSelectedProject(); + $project = $selection->getProject(); $settings = $project->getSettings(); - /** @var Table $table */ - $table = $this->getService('table'); - $isOriginalCommand = $input instanceof ArgvInput; - if (!$table->formatIsMachineReadable() && $isOriginalCommand) { - $this->stdErr->writeln(sprintf('Build resources for the project %s:', $this->api()->getProjectLabel($this->getSelectedProject()))); + if (!$this->table->formatIsMachineReadable() && $isOriginalCommand) { + $this->stdErr->writeln(sprintf('Build resources for the project %s:', $this->api->getProjectLabel($selection->getProject()))); } $rows = [ @@ -54,10 +62,10 @@ protected function execute(InputInterface $input, OutputInterface $output) ], ]; - $table->render($rows, $this->tableHeader); + $this->table->render($rows, $this->tableHeader); - if (!$table->formatIsMachineReadable() && $isOriginalCommand) { - $executable = $this->config()->get('application.executable'); + if (!$this->table->formatIsMachineReadable() && $isOriginalCommand) { + $executable = $this->config->getStr('application.executable'); $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf('Configure resources by running: %s resources:build:set', $executable)); } diff --git a/src/Command/Resources/Build/BuildResourcesSetCommand.php b/src/Command/Resources/Build/BuildResourcesSetCommand.php index 3b49c28977..fec39caf56 100644 --- a/src/Command/Resources/Build/BuildResourcesSetCommand.php +++ b/src/Command/Resources/Build/BuildResourcesSetCommand.php @@ -1,34 +1,47 @@ setName('resources:build:set') - ->setAliases(['build-resources:set']) - ->setDescription('Set the build resources of a project') - ->addOption('cpu', null, InputOption::VALUE_REQUIRED, 'Build CPU') - ->addOption('memory', null, InputOption::VALUE_REQUIRED, 'Build memory (in MB)') - ->addProjectOption(); + parent::__construct(); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function configure(): void { - $this->validateInput($input); - if (!$this->api()->supportsSizingApi($this->getSelectedProject())) { - $this->stdErr->writeln(sprintf('The flexible resources API is not enabled for the project %s.', $this->api()->getProjectLabel($this->getSelectedProject(), 'comment'))); + $this->addOption('cpu', null, InputOption::VALUE_REQUIRED, 'Build CPU') + ->addOption('memory', null, InputOption::VALUE_REQUIRED, 'Build memory (in MB)'); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $selection = $this->selector->getSelection($input); + if (!$this->api->supportsSizingApi($selection->getProject())) { + $this->stdErr->writeln(sprintf('The flexible resources API is not enabled for the project %s.', $this->api->getProjectLabel($selection->getProject(), 'comment'))); return 1; } - $project = $this->getSelectedProject(); + $project = $selection->getProject(); $capabilities = $project->getCapabilities(); $capability = $capabilities->getProperty('build_resources', false); @@ -37,10 +50,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $settings = $project->getSettings(); - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - - $validateCpu = function ($v) use ($maxCpu) { + $validateCpu = function ($v) use ($maxCpu): float { $f = (float) $v; if ($f != $v) { throw new InvalidArgumentException('The CPU value must be a number'); @@ -53,7 +63,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } return $f; }; - $validateMemory = function ($v) use ($maxMemory) { + $validateMemory = function ($v) use ($maxMemory): int { $i = (int) $v; if ($i != $v) { throw new InvalidArgumentException('The memory value must be an integer'); @@ -67,7 +77,7 @@ protected function execute(InputInterface $input, OutputInterface $output) return $i; }; - $this->stdErr->writeln('Update the build resources on the project: ' . $this->api()->getProjectLabel($project)); + $this->stdErr->writeln('Update the build resources on the project: ' . $this->api->getProjectLabel($project)); $cpuOption = $input->getOption('cpu'); $memoryOption = $input->getOption('memory'); @@ -88,20 +98,20 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(''); if ($cpuOption === null && $memoryOption === null) { - $cpuOption = $questionHelper->askInput( + $cpuOption = $this->questionHelper->askInput( 'CPU size', - $this->formatCPU($settings['build_resources']['cpu']), + $this->resourcesUtil->formatCPU($settings['build_resources']['cpu']), [], $validateCpu, - 'current: ' + 'current: ', ); - $memoryOption = $questionHelper->askInput( + $memoryOption = $this->questionHelper->askInput( 'Memory size in MB', $settings['build_resources']['memory'], [], $validateMemory, - 'current: ' + 'current: ', ); $this->stdErr->writeln(''); } @@ -121,10 +131,10 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->summarizeChanges($updates['build_resources'], $settings['build_resources']); - $this->debug('Raw updates: ' . json_encode($updates, JSON_UNESCAPED_SLASHES)); + $this->io->debug('Raw updates: ' . json_encode($updates, JSON_UNESCAPED_SLASHES)); $this->stdErr->writeln(''); - if (!$questionHelper->confirm('Are you sure you want to continue?')) { + if (!$this->questionHelper->confirm('Are you sure you want to continue?')) { return 1; } @@ -133,27 +143,27 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln('The settings were successfully updated.'); - return $this->runOtherCommand('resources:build:get', ['--project' => $project->id]); + return $this->subCommandRunner->run('resources:build:get', ['--project' => $project->id]); } /** * Summarizes all the changes that would be made. * - * @param array{cpu: int, memory: int} $updates + * @param array{cpu?: int, memory?: int} $updates * @param array{cpu: int, memory: int} $current * @return void */ - private function summarizeChanges(array $updates, array $current) + private function summarizeChanges(array $updates, array $current): void { $this->stdErr->writeln('Summary of changes:'); - $this->stdErr->writeln(' CPU: ' . $this->formatChange( - $this->formatCPU($current['cpu']), - $this->formatCPU(isset($updates['cpu']) ? $updates['cpu'] : $current['cpu']) + $this->stdErr->writeln(' CPU: ' . $this->resourcesUtil->formatChange( + $this->resourcesUtil->formatCPU($current['cpu']), + $this->resourcesUtil->formatCPU($updates['cpu'] ?? $current['cpu']), )); - $this->stdErr->writeln(' Memory: ' . $this->formatChange( + $this->stdErr->writeln(' Memory: ' . $this->resourcesUtil->formatChange( $current['memory'], - isset($updates['memory']) ? $updates['memory'] : $current['memory'], - ' MB' + $updates['memory'] ?? $current['memory'], + ' MB', )); } } diff --git a/src/Command/Resources/ResourcesCommandBase.php b/src/Command/Resources/ResourcesCommandBase.php index f1bda55b9f..c4ae8862d4 100644 --- a/src/Command/Resources/ResourcesCommandBase.php +++ b/src/Command/Resources/ResourcesCommandBase.php @@ -1,208 +1,25 @@ config()->get('api.sizing') || parent::isHidden(); - } - - /** - * Lists services in a deployment. - * - * @param EnvironmentDeployment $deployment - * - * @return array - * An array of services keyed by the service name. - */ - protected function allServices(EnvironmentDeployment $deployment) - { - $webapps = $deployment->webapps; - $workers = $deployment->workers; - $services = $deployment->services; - ksort($webapps, SORT_STRING|SORT_FLAG_CASE); - ksort($workers, SORT_STRING|SORT_FLAG_CASE); - ksort($services, SORT_STRING|SORT_FLAG_CASE); - return array_merge($webapps, $workers, $services); - } - - /** - * Checks whether a service needs a persistent disk. - * - * @param WebApp|Service|Worker $service - * @return bool - */ - protected function supportsDisk($service) - { - // Workers use the disk of their parent app. - if ($service instanceof Worker) { - return false; - } - return isset($service->getProperties()['resources']['minimum']['disk']); - } - - /** - * Loads the next environment deployment and caches it statically. - * - * The static cache means it can be reused while running a sub-command. - * - * @param Environment $environment - * @param bool $reset - * @return EnvironmentDeployment - */ - protected function loadNextDeployment(Environment $environment, $reset = false) - { - $cacheKey = $environment->project . ':' . $environment->id; - if (isset(self::$cachedNextDeployment[$cacheKey]) && !$reset) { - return self::$cachedNextDeployment[$cacheKey]; - } - $progress = new ProgressMessage($this->stdErr); - try { - $progress->show('Loading deployment information...'); - $next = $environment->getNextDeployment(); - if (!$next) { - throw new EnvironmentStateException('No next deployment found', $environment); - } - } finally { - $progress->done(); - } - return self::$cachedNextDeployment[$cacheKey] = $next; - } - - /** - * Filters a list of services according to the --service or --type options. - * - * @param array $services - * @param InputInterface $input - * - * @return WebApp[]|Service[]|Worker[]|false - * False on error, or an array of services. - */ - protected function filterServices($services, InputInterface $input) - { - $selectedNames = []; - - $requestedServices = ArrayArgument::getOption($input, 'service'); - if (!empty($requestedServices)) { - $selectedNames = Wildcard::select(array_keys($services), $requestedServices); - if (!$selectedNames) { - $this->stdErr->writeln('No services were found matching the name(s): ' . implode(', ', $requestedServices) . ''); - return false; - } - $services = array_intersect_key($services, array_flip($selectedNames)); - } - $requestedApps = ArrayArgument::getOption($input, 'app'); - if (!empty($requestedApps)) { - $selectedNames = Wildcard::select(array_keys(array_filter($services, function ($s) { return $s instanceof WebApp; })), $requestedApps); - if (!$selectedNames) { - $this->stdErr->writeln('No applications were found matching the name(s): ' . implode(', ', $requestedApps) . ''); - return false; - } - $services = array_intersect_key($services, array_flip($selectedNames)); - } - $requestedWorkers = ArrayArgument::getOption($input, 'worker'); - if (!empty($requestedWorkers)) { - $selectedNames = Wildcard::select(array_keys(array_filter($services, function ($s) { return $s instanceof Worker; })), $requestedWorkers); - if (!$selectedNames) { - $this->stdErr->writeln('No workers were found matching the name(s): ' . implode(', ', $requestedWorkers) . ''); - return false; - } - $services = array_intersect_key($services, array_flip($selectedNames)); - } - - if ($input->hasOption('type') && ($requestedTypes = ArrayArgument::getOption($input, 'type'))) { - $byType = []; - foreach ($services as $name => $service) { - $type = $service->type; - list($prefix) = explode(':', $service->type, 2); - $byType[$type][] = $name; - $byType[$prefix][] = $name; - } - $selectedTypes = Wildcard::select(array_keys($byType), $requestedTypes); - if (!$selectedTypes) { - $this->stdErr->writeln('No services were found matching the type(s): ' . implode(', ', $requestedTypes) . ''); - return false; - } - foreach ($selectedTypes as $selectedType) { - $selectedNames = array_merge($selectedNames, $byType[$selectedType]); - } - $services = array_intersect_key($services, array_flip($selectedNames)); - } - - return $services; - } - - /** - * Returns container profile size info, given service properties. - * - * @param array $properties - * The service properties (e.g. from $service->getProperties()). - * @param array $containerProfiles - * The list of container profiles (e.g. from - * $deployment->container_profiles). - * - * @return array{'cpu': string, 'memory': string}|null - */ - protected function sizeInfo(array $properties, array $containerProfiles) - { - if (isset($properties['resources']['profile_size'])) { - $size = $properties['resources']['profile_size']; - $profile = $properties['container_profile']; - if (isset($containerProfiles[$profile][$size])) { - return $containerProfiles[$profile][$size]; - } - } - return null; - } + private Config $config; - /** - * Formats a change in a value. - * - * @param int|float|string|null $previousValue - * @param int|float|string|null $newValue - * @param string $suffix A unit suffix e.g. ' MB' - * - * @return string - */ - protected function formatChange($previousValue, $newValue, $suffix = '') + #[Required] + public function autowire(Config $config): void { - if ($previousValue === null || $newValue === $previousValue) { - return sprintf('%s%s', $newValue, $suffix); - } - return sprintf( - '%s from %s%s to %s%s', - $newValue > $previousValue ? 'increasing' : 'decreasing', - $previousValue, $suffix, - $newValue, $suffix - ); + $this->config = $config; } - /** - * Formats a CPU amount. - * - * @param int|float|string $unformatted - * - * @return string - * A numeric (still comparable) string with 1 decimal place. - */ - protected function formatCPU($unformatted) + public function isHidden(): bool { - return sprintf('%.1f', $unformatted); + return !$this->config->getBool('api.sizing') || parent::isHidden(); } } diff --git a/src/Command/Resources/ResourcesGetCommand.php b/src/Command/Resources/ResourcesGetCommand.php index 3693d8eefb..8364a1161d 100644 --- a/src/Command/Resources/ResourcesGetCommand.php +++ b/src/Command/Resources/ResourcesGetCommand.php @@ -1,17 +1,27 @@ */ + protected array $tableHeader = [ 'service' => 'App or service', 'type' => 'Type', 'profile' => 'Profile', @@ -23,67 +33,66 @@ class ResourcesGetCommand extends ResourcesCommandBase 'base_memory' => 'Base memory', 'memory_ratio' => 'Memory ratio', ]; - protected $defaultColumns = ['service', 'profile_size', 'cpu', 'memory', 'disk', 'instance_count']; + /** @var string[] */ + protected array $defaultColumns = ['service', 'profile_size', 'cpu', 'memory', 'disk', 'instance_count']; + public function __construct(private readonly Api $api, private readonly Config $config, private readonly PropertyFormatter $propertyFormatter, private readonly ResourcesUtil $resourcesUtil, private readonly Selector $selector, private readonly Table $table) + { + parent::__construct(); + } - protected function configure() + protected function configure(): void { - $this->setName('resources:get') - ->setAliases(['resources', 'res']) - ->setDescription('View the resources of apps and services on an environment') - ->addOption('service', 's', InputOption::VALUE_REQUIRED|InputOption::VALUE_IS_ARRAY, 'Filter by service name. This can select any service, including apps and workers.') - ->addOption('app', null, InputOption::VALUE_REQUIRED|InputOption::VALUE_IS_ARRAY, 'Filter by app name') - ->addOption('worker', null, InputOption::VALUE_REQUIRED|InputOption::VALUE_IS_ARRAY, 'Filter by worker name') + $this + ->addOption('service', 's', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter by service name. This can select any service, including apps and workers.') + ->addOption('app', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter by app name') + ->addOption('worker', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter by worker name') ->addOption('type', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Filter by service, app or worker type, e.g. "postgresql"'); - $this->addProjectOption()->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); - if ($this->config()->has('service.resources_help_url')) { - $this->setHelp('For more information on managing resources, see: ' . $this->config()->get('service.resources_help_url') . ''); + if ($this->config->has('service.resources_help_url')) { + $this->setHelp('For more information on managing resources, see: ' . $this->config->getStr('service.resources_help_url') . ''); } } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); - if (!$this->api()->supportsSizingApi($this->getSelectedProject())) { - $this->stdErr->writeln(sprintf('The flexible resources API is not enabled for the project %s.', $this->api()->getProjectLabel($this->getSelectedProject(), 'comment'))); + $selection = $this->selector->getSelection($input); + if (!$this->api->supportsSizingApi($selection->getProject())) { + $this->stdErr->writeln(sprintf('The flexible resources API is not enabled for the project %s.', $this->api->getProjectLabel($selection->getProject(), 'comment'))); return 1; } - $environment = $this->getSelectedEnvironment(); + $environment = $selection->getEnvironment(); try { - $nextDeployment = $this->loadNextDeployment($environment); + $nextDeployment = $this->resourcesUtil->loadNextDeployment($environment); } catch (EnvironmentStateException $e) { if ($environment->status === 'inactive') { - $this->stdErr->writeln(sprintf('The environment %s is not active so resource configuration cannot be read.', $this->api()->getEnvironmentLabel($environment, 'comment'))); + $this->stdErr->writeln(sprintf('The environment %s is not active so resource configuration cannot be read.', $this->api->getEnvironmentLabel($environment, 'comment'))); return 1; } throw $e; } - $services = $this->allServices($nextDeployment); + $services = $this->resourcesUtil->allServices($nextDeployment); if (empty($services)) { $this->stdErr->writeln('No apps or services found'); return 1; } - $services = $this->filterServices($services, $input); + $services = $this->resourcesUtil->filterServices($services, $input); if (empty($services)) { return 1; } - /** @var Table $table */ - $table = $this->getService('table'); - - if (!$table->formatIsMachineReadable()) { - $this->stdErr->writeln(sprintf('Resource configuration for the project %s, environment %s:', $this->api()->getProjectLabel($this->getSelectedProject()), $this->api()->getEnvironmentLabel($environment))); + if (!$this->table->formatIsMachineReadable()) { + $this->stdErr->writeln(sprintf('Resource configuration for the project %s, environment %s:', $this->api->getProjectLabel($selection->getProject()), $this->api->getEnvironmentLabel($environment))); } - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - - $empty = $table->formatIsMachineReadable() ? '' : 'not set'; - $notApplicable = $table->formatIsMachineReadable() ? '' : 'N/A'; + $empty = $this->table->formatIsMachineReadable() ? '' : 'not set'; + $notApplicable = $this->table->formatIsMachineReadable() ? '' : 'N/A'; $containerProfiles = $nextDeployment->container_profiles; @@ -92,7 +101,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $properties = $service->getProperties(); $row = [ 'service' => $name, - 'type' => $formatter->format($service->type, 'service_type'), + 'type' => $this->propertyFormatter->format($service->type, 'service_type'), 'profile' => $properties['container_profile'] ?: $empty, 'profile_size' => $empty, 'base_memory' => $empty, @@ -105,7 +114,7 @@ protected function execute(InputInterface $input, OutputInterface $output) if (isset($properties['container_profile']) && isset($containerProfiles[$properties['container_profile']][$properties['resources']['profile_size']])) { $profileInfo = $containerProfiles[$properties['container_profile']][$properties['resources']['profile_size']]; - $row['cpu'] = isset($profileInfo['cpu']) ? $this->formatCPU($profileInfo['cpu']) : ''; + $row['cpu'] = isset($profileInfo['cpu']) ? $this->resourcesUtil->formatCPU($profileInfo['cpu']) : ''; $row['memory'] = isset($profileInfo['cpu']) ? $profileInfo['memory'] : ''; } @@ -118,25 +127,25 @@ protected function execute(InputInterface $input, OutputInterface $output) } } - if (!$this->supportsDisk($service)) { + if (!$this->resourcesUtil->supportsDisk($service)) { $row['disk'] = $notApplicable; } elseif (array_key_exists('disk', $properties)) { if (empty($properties['disk'])) { $row['disk'] = empty($properties['resources']) ? $empty : ''; } else { - $row['disk'] = $formatter->format($properties['disk'], 'disk'); + $row['disk'] = $this->propertyFormatter->format($properties['disk'], 'disk'); } } - $row['instance_count'] = isset($properties['instance_count']) ? $formatter->format($properties['instance_count'], 'instance_count') : '1'; + $row['instance_count'] = isset($properties['instance_count']) ? $this->propertyFormatter->format($properties['instance_count'], 'instance_count') : '1'; $rows[] = $row; } - $table->render($rows, $this->tableHeader, $this->defaultColumns); + $this->table->render($rows, $this->tableHeader, $this->defaultColumns); - if (!$table->formatIsMachineReadable()) { - $executable = $this->config()->get('application.executable'); + if (!$this->table->formatIsMachineReadable()) { + $executable = $this->config->getStr('application.executable'); $isOriginalCommand = $input instanceof ArgvInput; if ($isOriginalCommand) { $this->stdErr->writeln(''); diff --git a/src/Command/Resources/ResourcesSetCommand.php b/src/Command/Resources/ResourcesSetCommand.php index 7b3d759d48..887d851757 100644 --- a/src/Command/Resources/ResourcesSetCommand.php +++ b/src/Command/Resources/ResourcesSetCommand.php @@ -1,7 +1,17 @@ setName('resources:set') - ->setDescription('Set the resources of apps and services on an environment') - ->addOption('size', 'S', InputOption::VALUE_REQUIRED|InputOption::VALUE_IS_ARRAY, - 'Set the profile size (CPU and memory) of apps, workers, or services.' + $this->addOption( + 'size', + 'S', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + 'Set the profile size (CPU and memory) of apps, workers, or services.' . "\nItems are in the format name:value and may be comma-separated." . "\nThe % or * characters may be used as a wildcard for the name." . "\nList available sizes with the resources:sizes command." - . "\nA value of 'default' will use the default size, and 'min' or 'minimum' will use the minimum." - ) - ->addOption('count', 'C', InputOption::VALUE_REQUIRED|InputOption::VALUE_IS_ARRAY, + . "\nA value of 'default' will use the default size, and 'min' or 'minimum' will use the minimum.", + ) + ->addOption( + 'count', + 'C', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Set the instance count of apps or workers.' - . "\nItems are in the format name:value as above." + . "\nItems are in the format name:value as above.", ) - ->addOption('disk', 'D', InputOption::VALUE_REQUIRED|InputOption::VALUE_IS_ARRAY, + ->addOption( + 'disk', + 'D', + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Set the disk size (in MB) of apps or services.' . "\nItems are in the format name:value as above." - . "\nA value of 'default' will use the default size, and 'min' or 'minimum' will use the minimum." + . "\nA value of 'default' will use the default size, and 'min' or 'minimum' will use the minimum.", ) ->addOption('force', 'f', InputOption::VALUE_NONE, 'Try to run the update, even if it might exceed your limits') - ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show the changes that would be made, without changing anything') - ->addProjectOption() - ->addEnvironmentOption() - ->addWaitOptions(); + ->addOption('dry-run', null, InputOption::VALUE_NONE, 'Show the changes that would be made, without changing anything'); + + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); $helpLines = [ 'Configure the resources allocated to apps, workers and services on an environment.', '', 'The resources may be the profile size, the instance count, or the disk size (MB).', '', - sprintf('Profile sizes are predefined CPU & memory values that can be viewed by running: %s resources:sizes', $this->config()->get('application.executable')), + sprintf('Profile sizes are predefined CPU & memory values that can be viewed by running: %s resources:sizes', $this->config->getStr('application.executable')), '', - 'If the same service and resource is specified on the command line multiple times, only the final value will be used.' + 'If the same service and resource is specified on the command line multiple times, only the final value will be used.', ]; - if ($this->config()->has('service.resources_help_url')) { + if ($this->config->has('service.resources_help_url')) { $helpLines[] = ''; - $helpLines[] = 'For more information on managing resources, see: ' . $this->config()->get('service.resources_help_url') . ''; + $helpLines[] = 'For more information on managing resources, see: ' . $this->config->getStr('service.resources_help_url') . ''; } $this->setHelp(implode("\n", $helpLines)); @@ -65,27 +90,27 @@ protected function configure() $this->addExample('Set the same instance count for all apps using a wildcard', '--count ' . OsUtil::escapeShellArg('*:3')); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); - if (!$this->api()->supportsSizingApi($this->getSelectedProject())) { - $this->stdErr->writeln(sprintf('The flexible resources API is not enabled for the project %s.', $this->api()->getProjectLabel($this->getSelectedProject(), 'comment'))); + $selection = $this->selector->getSelection($input); + if (!$this->api->supportsSizingApi($selection->getProject())) { + $this->stdErr->writeln(sprintf('The flexible resources API is not enabled for the project %s.', $this->api->getProjectLabel($selection->getProject(), 'comment'))); return 1; } - $environment = $this->getSelectedEnvironment(); + $environment = $selection->getEnvironment(); try { - $nextDeployment = $this->loadNextDeployment($environment); + $nextDeployment = $this->resourcesUtil->loadNextDeployment($environment); } catch (EnvironmentStateException $e) { if ($environment->status === 'inactive') { - $this->stdErr->writeln(sprintf('The environment %s is not active so resources cannot be configured.', $this->api()->getEnvironmentLabel($environment, 'comment'))); + $this->stdErr->writeln(sprintf('The environment %s is not active so resources cannot be configured.', $this->api->getEnvironmentLabel($environment, 'comment'))); return 1; } throw $e; } - $services = $this->allServices($nextDeployment); + $services = $this->resourcesUtil->allServices($nextDeployment); if (empty($services)) { $this->stdErr->writeln('No apps or services found'); return 1; @@ -98,36 +123,27 @@ protected function execute(InputInterface $input, OutputInterface $output) } // Validate the --size option. - list($givenSizes, $errored) = $this->parseSetting($input, 'size', $services, function ($v, $serviceName, $service) use ($nextDeployment) { - return $this->validateProfileSize($v, $serviceName, $service, $nextDeployment); - }); + [$givenSizes, $errored] = $this->parseSetting($input, 'size', $services, fn($v, $serviceName, $service) => $this->validateProfileSize($v, $serviceName, $service, $nextDeployment)); // Validate the --count option. - list($givenCounts, $countErrored) = $this->parseSetting($input, 'count', $services, function ($v, $serviceName, $service) use ($instanceLimit) { - return $this->validateInstanceCount($v, $serviceName, $service, $instanceLimit); - }); + [$givenCounts, $countErrored] = $this->parseSetting($input, 'count', $services, fn($v, $serviceName, $service) => $this->validateInstanceCount($v, $serviceName, $service, $instanceLimit)); $errored = $errored || $countErrored; // Validate the --disk option. - list($givenDiskSizes, $diskErrored) = $this->parseSetting($input, 'disk', $services, function ($v, $serviceName, $service) { - return $this->validateDiskSize($v, $serviceName, $service); - }); + [$givenDiskSizes, $diskErrored] = $this->parseSetting($input, 'disk', $services, fn($v, $serviceName, $service) => $this->validateDiskSize($v, $serviceName, $service)); $errored = $errored || $diskErrored; if ($errored) { return 1; } - if (($exitCode = $this->runOtherCommand('resources:get', [ - '--project' => $environment->project, - '--environment' => $environment->id, - ], $this->stdErr)) !== 0) { + if (($exitCode = $this->subCommandRunner->run('resources:get', [ + '--project' => $environment->project, + '--environment' => $environment->id, + ], $this->stdErr)) !== 0) { return $exitCode; } $this->stdErr->writeln(''); - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $containerProfiles = $nextDeployment->container_profiles; // Ask all questions if nothing was specified on the command line. @@ -150,7 +166,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $header = '' . ucfirst($type) . ': ' . $name . ''; $headerShown = false; - $ensureHeader = function () use (&$headerShown, &$header) { + $ensureHeader = function () use (&$headerShown, &$header): void { if (!$headerShown) { $this->stdErr->writeln($header); $this->stdErr->writeln(''); @@ -199,8 +215,8 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(sprintf('No profile size can be found for the %s %s which satisfies its minimum resources.', $type, $name)); $errored = true; } else { - $profileSize = $questionHelper->chooseAssoc($options, sprintf('Choose %s profile size:', $new), $defaultOption, false, false); - if (!isset($properties['resources']['profile_size']) || $profileSize != $properties['resources']['profile_size']) { + $profileSize = $this->questionHelper->chooseAssoc($options, sprintf('Choose %s profile size:', $new), $defaultOption, false, false); + if (!isset($properties['resources']['profile_size']) || $profileSize !== $properties['resources']['profile_size']) { $updates[$group][$name]['resources']['profile_size'] = $profileSize; } } @@ -219,9 +235,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } elseif ($showCompleteForm) { $ensureHeader(); $default = $properties['instance_count'] ?: 1; - $instanceCount = $questionHelper->askInput('Enter the number of instances', $default, [], function ($v) use ($name, $service, $instanceLimit) { - return $this->validateInstanceCount($v, $name, $service, $instanceLimit); - }); + $instanceCount = $this->questionHelper->askInput('Enter the number of instances', $default, [], fn($v) => $this->validateInstanceCount($v, $name, $service, $instanceLimit)); if ($instanceCount !== $properties['instance_count']) { $updates[$group][$name]['instance_count'] = $instanceCount; } @@ -229,7 +243,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } // Set the disk size. - if ($this->supportsDisk($service)) { + if ($this->resourcesUtil->supportsDisk($service)) { if (isset($givenDiskSizes[$name])) { if ($givenDiskSizes[$name] !== $service->disk) { $updates[$group][$name]['disk'] = $givenDiskSizes[$name]; @@ -239,11 +253,9 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($service->disk) { $default = $service->disk; } else { - $default = isset($properties['resources']['default']['disk']) ? $properties['resources']['default']['disk'] : '512'; + $default = $properties['resources']['default']['disk'] ?? '512'; } - $diskSize = $questionHelper->askInput('Enter a disk size in MB', $default, ['512', '1024', '2048'], function ($v) use ($name, $service) { - return $this->validateDiskSize($v, $name, $service); - }); + $diskSize = $this->questionHelper->askInput('Enter a disk size in MB', $default, ['512', '1024', '2048'], fn($v) => $this->validateDiskSize($v, $name, $service)); if ($diskSize !== $service->disk) { $updates[$group][$name]['disk'] = $diskSize; } @@ -269,25 +281,28 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->summarizeChanges($updates, $services, $containerProfiles); - $this->debug('Raw updates: ' . json_encode($updates, JSON_UNESCAPED_SLASHES)); + $this->io->debug('Raw updates: ' . json_encode($updates, JSON_UNESCAPED_SLASHES)); - $project = $this->getSelectedProject(); - $organization = $this->api()->getClient()->getOrganizationById($project->getProperty('organization')); + $project = $selection->getProject(); + $organization = $this->api->getClient()->getOrganizationById($project->getProperty('organization')); + if (!$organization) { + throw new \RuntimeException('Failed to load project organization: ' . $project->getProperty('organization')); + } $profile = $organization->getProfile(); if ($input->getOption('force') === false && isset($profile->resources_limit) && $profile->resources_limit) { $diff = $this->computeMemoryCPUStorageDiff($updates, $current); $limit = $profile->resources_limit['limit']; $used = $profile->resources_limit['used']['totals']; - $this->debug('Raw diff: ' . json_encode($diff, JSON_UNESCAPED_SLASHES)); - $this->debug('Raw limits: ' . json_encode($limit, JSON_UNESCAPED_SLASHES)); - $this->debug('Raw used: ' . json_encode($used, JSON_UNESCAPED_SLASHES)); + $this->io->debug('Raw diff: ' . json_encode($diff, JSON_UNESCAPED_SLASHES)); + $this->io->debug('Raw limits: ' . json_encode($limit, JSON_UNESCAPED_SLASHES)); + $this->io->debug('Raw used: ' . json_encode($used, JSON_UNESCAPED_SLASHES)); $errored = false; if ($limit['cpu'] < $used['cpu'] + $diff['cpu']) { $this->stdErr->writeln(sprintf( 'The requested resources will exceed your organization\'s trial CPU limit, which is: %s.', - $limit['cpu'] + $limit['cpu'], )); $errored = true; } @@ -295,7 +310,7 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($limit['memory'] < $used['memory'] + ($diff['memory'] / 1024)) { $this->stdErr->writeln(sprintf( 'The requested resources will exceed your organization\'s trial memory limit, which is: %sGB.', - $limit['memory'] + $limit['memory'], )); $errored = true; } @@ -303,7 +318,7 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($limit['storage'] < $used['storage'] + ($diff['disk'] / 1024)) { $this->stdErr->writeln(sprintf( 'The requested resources will exceed your organization\'s trial storage limit, which is: %sGB.', - $limit['storage'] + $limit['storage'], )); $errored = true; } @@ -319,18 +334,17 @@ protected function execute(InputInterface $input, OutputInterface $output) } $this->stdErr->writeln(''); - if (!$questionHelper->confirm('Are you sure you want to continue?')) { + if (!$this->questionHelper->confirm('Are you sure you want to continue?')) { return 1; } $this->stdErr->writeln(''); - $this->stdErr->writeln('Setting the resources on the environment ' . $this->api()->getEnvironmentLabel($environment)); + $this->stdErr->writeln('Setting the resources on the environment ' . $this->api->getEnvironmentLabel($environment)); $result = $nextDeployment->update($updates); - if ($this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); - $success = $activityMonitor->waitMultiple($result->getActivities(), $this->getSelectedProject()); + if ($this->activityMonitor->shouldWait($input)) { + $activityMonitor = $this->activityMonitor; + $success = $activityMonitor->waitMultiple($result->getActivities(), $selection->getProject()); if (!$success) { return 1; } @@ -342,12 +356,12 @@ protected function execute(InputInterface $input, OutputInterface $output) /** * Summarizes all the changes that would be made. * - * @param array $updates + * @param array>> $updates * @param array $services - * @param array $containerProfiles + * @param array $containerProfiles * @return void */ - private function summarizeChanges(array $updates, $services, array $containerProfiles) + private function summarizeChanges(array $updates, array $services, array $containerProfiles): void { $this->stdErr->writeln('Summary of changes:'); foreach ($updates as $groupUpdates) { @@ -360,53 +374,47 @@ private function summarizeChanges(array $updates, $services, array $containerPro /** * Summarizes changes per service. * - * @param string $name The service name - * @param WebApp|Worker|Service $service - * @param array $updates - * @param array $containerProfiles - * @return void + * @param array $updates + * @param array $containerProfiles */ - private function summarizeChangesPerService($name, $service, array $updates, array $containerProfiles) + private function summarizeChangesPerService(string $name, WebApp|Worker|Service $service, array $updates, array $containerProfiles): void { $this->stdErr->writeln(sprintf(' %s: %s', ucfirst($this->typeName($service)), $name)); $properties = $service->getProperties(); if (isset($updates['resources']['profile_size'])) { - $sizeInfo = $this->sizeInfo($properties, $containerProfiles); + $sizeInfo = $this->resourcesUtil->sizeInfo($properties, $containerProfiles); $newProperties = array_replace_recursive($properties, $updates); - $newSizeInfo = $this->sizeInfo($newProperties, $containerProfiles); - $this->stdErr->writeln(' CPU: ' . $this->formatChange( - $this->formatCPU($sizeInfo ? $sizeInfo['cpu'] : null), - $this->formatCPU($newSizeInfo['cpu']) + $newSizeInfo = $this->resourcesUtil->sizeInfo($newProperties, $containerProfiles); + $this->stdErr->writeln(' CPU: ' . $this->resourcesUtil->formatChange( + $this->resourcesUtil->formatCPU($sizeInfo ? $sizeInfo['cpu'] : null), + $this->resourcesUtil->formatCPU($newSizeInfo['cpu']), )); - $this->stdErr->writeln(' Memory: ' . $this->formatChange( + $this->stdErr->writeln(' Memory: ' . $this->resourcesUtil->formatChange( $sizeInfo ? $sizeInfo['memory'] : null, $newSizeInfo['memory'], - ' MB' + ' MB', )); } if (isset($updates['instance_count'])) { - $this->stdErr->writeln(' Instance count: ' . $this->formatChange( - isset($properties['instance_count']) ? $properties['instance_count'] : 1, - $updates['instance_count'] + $this->stdErr->writeln(' Instance count: ' . $this->resourcesUtil->formatChange( + $properties['instance_count'] ?? 1, + $updates['instance_count'], )); } if (isset($updates['disk'])) { - $this->stdErr->writeln(' Disk: ' . $this->formatChange( - isset($properties['disk']) ? $properties['disk'] : null, + $this->stdErr->writeln(' Disk: ' . $this->resourcesUtil->formatChange( + $properties['disk'] ?? null, $updates['disk'], - ' MB' + ' MB', )); } } /** * Returns the group for a service (where it belongs in the deployment object). - * - * @param Service|WebApp|Worker $service - * @return string */ - protected function group($service) + protected function group(WebApp|Worker|Service $service): string { if ($service instanceof WebApp) { return 'webapps'; @@ -419,12 +427,8 @@ protected function group($service) /** * Returns the service type name for a service. - * - * @param Service|WebApp|Worker $service - * - * @return string */ - protected function typeName($service) + protected function typeName(WebApp|Worker|Service $service): string { if ($service instanceof WebApp) { return 'app'; @@ -438,16 +442,9 @@ protected function typeName($service) /** * Validates a given instance count. * - * @param string $value - * @param string $serviceName - * @param Service|WebApp|Worker $service - * @param int|null $limit - * * @throws InvalidArgumentException - * - * @return int */ - protected function validateInstanceCount($value, $serviceName, $service, $limit) + protected function validateInstanceCount(string $value, string $serviceName, WebApp|Worker|Service $service, ?int $limit): int { if ($service instanceof Service) { throw new InvalidArgumentException(sprintf('The instance count of the service %s cannot be changed.', $serviceName)); @@ -463,27 +460,24 @@ protected function validateInstanceCount($value, $serviceName, $service, $limit) } /** - * Validate a given disk size. - * - * @param string $value - * @param string $serviceName - * @param Service|WebApp|Worker $service + * Validates a given disk size. * * @throws InvalidArgumentException - * - * @return int */ - protected function validateDiskSize($value, $serviceName, $service) + protected function validateDiskSize(string $value, string $serviceName, WebApp|Worker|Service $service): int { - if (!$this->supportsDisk($service)) { + if (!$this->resourcesUtil->supportsDisk($service)) { throw new InvalidArgumentException(sprintf( - 'The %s %s does not support a persistent disk.', $this->typeName($service), $serviceName + 'The %s %s does not support a persistent disk.', + $this->typeName($service), + $serviceName, )); } $size = (int) $value; if ($size != $value || $value < 0) { throw new InvalidArgumentException(sprintf( - 'Invalid disk size %s: it must be an integer in MB.', $value + 'Invalid disk size %s: it must be an integer in MB.', + $value, )); } $properties = $service->getProperties(); @@ -502,25 +496,20 @@ protected function validateDiskSize($value, $serviceName, $service) if (isset($properties['resources']['minimum']['disk']) && $value < $properties['resources']['minimum']['disk']) { throw new InvalidArgumentException(sprintf( 'Invalid disk size %s: the minimum size for this %s is %d MB.', - $value, $this->typeName($service), $properties['resources']['minimum']['disk'] + $value, + $this->typeName($service), + $properties['resources']['minimum']['disk'], )); } return $size; } /** - * Validate a given profile size. - * - * @param string $value - * @param string $serviceName - * @param Service|WebApp|Worker $service - * @param EnvironmentDeployment $deployment + * Validates a given profile size. * * @throws InvalidArgumentException - * - * @return string */ - protected function validateProfileSize($value, $serviceName, $service, EnvironmentDeployment $deployment) + protected function validateProfileSize(string $value, string $serviceName, WebApp|Worker|Service $service, EnvironmentDeployment $deployment): string { $properties = $service->getProperties(); if ($value === 'default') { @@ -547,13 +536,19 @@ protected function validateProfileSize($value, $serviceName, $service, Environme if (isset($resources['minimum']['cpu'], $sizeInfo['cpu']) && $sizeInfo['cpu'] < $resources['minimum']['cpu']) { throw new InvalidArgumentException(sprintf( 'Invalid profile size %s: its CPU amount %d is below the minimum for this %s, %d', - $sizeName, $sizeInfo['cpu'], $this->typeName($service), $resources['minimum']['cpu'] + $sizeName, + $sizeInfo['cpu'], + $this->typeName($service), + $resources['minimum']['cpu'], )); } if (isset($resources['minimum']['memory'], $sizeInfo['memory']) && $sizeInfo['memory'] < $resources['minimum']['memory']) { throw new InvalidArgumentException(sprintf( 'Invalid profile size %s: its memory amount %d MB is below the minimum for this %s, %d MB', - $sizeName, $sizeInfo['memory'], $this->typeName($service), $resources['minimum']['memory'] + $sizeName, + $sizeInfo['memory'], + $this->typeName($service), + $resources['minimum']['memory'], )); } return (string) $sizeName; @@ -576,7 +571,7 @@ protected function validateProfileSize($value, $serviceName, $service, Environme * @return array{array, bool} * An array of settings per service, and whether an error occurred. */ - private function parseSetting(InputInterface $input, $optionName, $services, $validator) + private function parseSetting(InputInterface $input, string $optionName, array $services, ?callable $validator): array { $items = ArrayArgument::getOption($input, $optionName); $serviceNames = array_keys($services); @@ -587,7 +582,7 @@ private function parseSetting(InputInterface $input, $optionName, $services, $va $errors[] = sprintf('%s is not valid; it must be in the format "name:value".', $item); continue; } - list($pattern, $value) = $parts; + [$pattern, $value] = $parts; $givenServiceNames = Wildcard::select($serviceNames, [$pattern]); if (empty($givenServiceNames)) { $errors[] = sprintf('App or service %s not found.', $pattern); @@ -601,7 +596,7 @@ private function parseSetting(InputInterface $input, $optionName, $services, $va continue; } if (isset($values[$name]) && $values[$name] !== $normalized) { - $this->debug(sprintf('Overriding value %s with %s for %s in --%s', $values[$name], $normalized, $name, $optionName)); + $this->io->debug(sprintf('Overriding value %s with %s for %s in --%s', $values[$name], $normalized, $name, $optionName)); } $values[$name] = $normalized; } @@ -616,12 +611,12 @@ private function parseSetting(InputInterface $input, $optionName, $services, $va /** * Print errors found after parsing a setting. * - * @param array $errors + * @param string[] $errors * @param string $optionName * * @return string[] */ - private function formatErrors(array $errors, $optionName) + private function formatErrors(array $errors, string $optionName): array { if (!$errors) { return []; @@ -643,12 +638,12 @@ private function formatErrors(array $errors, $optionName) * Compute the total memory/CPU/storage diff that will occur when the given update * is applied. * - * @param array $updates - * @param array $current + * @param array>> $updates + * @param array>> $current * - * @return array + * @return array{memory: int|float, cpu: int|float, disk: int|float} */ - private function computeMemoryCPUStorageDiff(array $updates, array $current) + private function computeMemoryCPUStorageDiff(array $updates, array $current): array { $diff = [ 'memory' => 0, diff --git a/src/Command/Resources/ResourcesSizeListCommand.php b/src/Command/Resources/ResourcesSizeListCommand.php index 62f0d52436..c5a777e29e 100644 --- a/src/Command/Resources/ResourcesSizeListCommand.php +++ b/src/Command/Resources/ResourcesSizeListCommand.php @@ -1,40 +1,53 @@ 'Size name', 'cpu' => 'CPU', 'memory' => 'Memory (MB)']; + /** @var array */ + protected array $tableHeader = ['size' => 'Size name', 'cpu' => 'CPU', 'memory' => 'Memory (MB)']; + public function __construct(private readonly Api $api, private readonly QuestionHelper $questionHelper, private readonly ResourcesUtil $resourcesUtil, private readonly Selector $selector, private readonly Table $table) + { + parent::__construct(); + } - protected function configure() + protected function configure(): void { - $this->setName('resources:size:list') - ->setAliases(['resources:sizes']) - ->setDescription('List container profile sizes') + $this ->addOption('service', 's', InputOption::VALUE_REQUIRED, 'A service name') ->addOption('profile', null, InputOption::VALUE_REQUIRED, 'A profile name'); - $this->addProjectOption()->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); Table::configureInput($this->getDefinition(), $this->tableHeader); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); - if (!$this->api()->supportsSizingApi($this->getSelectedProject())) { - $this->stdErr->writeln(sprintf('The flexible resources API is not enabled for the project %s.', $this->api()->getProjectLabel($this->getSelectedProject(), 'comment'))); + $selection = $this->selector->getSelection($input); + if (!$this->api->supportsSizingApi($selection->getProject())) { + $this->stdErr->writeln(sprintf('The flexible resources API is not enabled for the project %s.', $this->api->getProjectLabel($selection->getProject(), 'comment'))); return 1; } - $environment = $this->getSelectedEnvironment(); - $nextDeployment = $this->loadNextDeployment($environment); + $environment = $selection->getEnvironment(); + $nextDeployment = $this->resourcesUtil->loadNextDeployment($environment); - $services = $this->allServices($nextDeployment); + $services = $this->resourcesUtil->allServices($nextDeployment); if (empty($services)) { $this->stdErr->writeln('No apps or services found'); return 1; @@ -60,13 +73,11 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } } elseif ($input->isInteractive()) { - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); $options = []; foreach ($servicesByProfile as $profile => $serviceNames) { $options[$profile] = sprintf('%s (for %s: %s)', $profile, count($serviceNames) === 1 ? 'service' : 'services', implode(', ', $serviceNames)); } - $profile = $questionHelper->choose($options, 'Enter a number to choose a container profile:'); + $profile = $this->questionHelper->choose($options, 'Enter a number to choose a container profile:'); } elseif (count($services) === 1) { $service = reset($services); $profile = $service->container_profile; @@ -74,28 +85,25 @@ protected function execute(InputInterface $input, OutputInterface $output) throw new InvalidArgumentException('The --service or --profile is required.'); } - /** @var Table $table */ - $table = $this->getService('table'); - $rows = []; foreach ($containerProfiles[$profile] as $sizeName => $sizeInfo) { - $rows[] = ['size' => $sizeName, 'cpu' => $this->formatCPU($sizeInfo['cpu']), 'memory' => $sizeInfo['memory']]; + $rows[] = ['size' => $sizeName, 'cpu' => $this->resourcesUtil->formatCPU($sizeInfo['cpu']), 'memory' => $sizeInfo['memory']]; } - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { if (!empty($servicesByProfile[$profile])) { $this->stdErr->writeln(sprintf( 'Available sizes in the container profile %s (for %s: %s):', $profile, count($servicesByProfile[$profile]) === 1 ? 'service' : 'services', - implode(', ', $servicesByProfile[$profile]) + implode(', ', $servicesByProfile[$profile]), )); } else { $this->stdErr->writeln(sprintf('Available sizes in the container profile %s:', $profile)); } } - $table->render($rows, $this->tableHeader); + $this->table->render($rows, $this->tableHeader); return 0; } diff --git a/src/Command/Route/RouteGetCommand.php b/src/Command/Route/RouteGetCommand.php index f07d8d0635..1ae1407eef 100644 --- a/src/Command/Route/RouteGetCommand.php +++ b/src/Command/Route/RouteGetCommand.php @@ -1,61 +1,72 @@ setName('route:get') - ->setDescription('View detailed information about a route') ->addArgument('route', InputArgument::OPTIONAL, "The route's original URL") ->addOption('id', null, InputOption::VALUE_REQUIRED, 'A route ID to select') ->addOption('primary', '1', InputOption::VALUE_NONE, 'Select the primary route') ->addOption('property', 'P', InputOption::VALUE_REQUIRED, 'The property to display') ->addOption('refresh', null, InputOption::VALUE_NONE, 'Bypass the cache of routes'); PropertyFormatter::configureInput($this->getDefinition()); - $this->addProjectOption() - ->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); $this->addOption('app', 'A', InputOption::VALUE_REQUIRED, '[Deprecated option, no longer used]'); $this->addOption('identity-file', 'i', InputOption::VALUE_REQUIRED, '[Deprecated option, no longer used]'); $this->addExample('View the URL to the https://{default}/ route', "'https://{default}/' -P url"); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { // Allow override via PLATFORM_ROUTES. - $prefix = $this->config()->get('service.env_prefix'); + $prefix = $this->config->getStr('service.env_prefix'); if (getenv($prefix . 'ROUTES') && !LocalHost::conflictsWithCommandLineOptions($input, $prefix)) { - $this->debug('Reading routes from environment variable ' . $prefix . 'ROUTES'); - $decoded = json_decode(base64_decode(getenv($prefix . 'ROUTES'), true), true); + $this->io->debug('Reading routes from environment variable ' . $prefix . 'ROUTES'); + $decoded = json_decode((string) base64_decode(getenv($prefix . 'ROUTES'), true), true); if (empty($decoded)) { throw new \RuntimeException('Failed to decode: ' . $prefix . 'ROUTES'); } $routes = Route::fromVariables($decoded); } else { - $this->debug('Reading routes from the API'); - $this->validateInput($input); - $environment = $this->getSelectedEnvironment(); - $deployment = $this->api() + $this->io->debug('Reading routes from the API'); + $selection = $this->selector->getSelection($input); + $environment = $selection->getEnvironment(); + $deployment = $this->api ->getCurrentDeployment($environment, $input->getOption('refresh')); $routes = Route::fromDeploymentApi($deployment->routes); } - $this->warnAboutDeprecatedOptions(['app', 'identity-file']); + $this->io->warnAboutDeprecatedOptions(['app', 'identity-file']); - /** @var \Platformsh\Cli\Model\Route|false $selectedRoute */ + /** @var Route|false $selectedRoute */ $selectedRoute = false; $id = $input->getOption('id'); @@ -92,8 +103,6 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); $items = []; $default = null; foreach ($routes as $route) { @@ -107,7 +116,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $items[$originalUrl] .= ' - primary'; } } - $originalUrl = $questionHelper->choose($items, 'Enter a number to choose a route:', $default); + $originalUrl = $this->questionHelper->choose($items, 'Enter a number to choose a route:', $default); } if (!$selectedRoute && $originalUrl !== null && $originalUrl !== '') { @@ -134,10 +143,7 @@ protected function execute(InputInterface $input, OutputInterface $output) // Add defaults. $selectedRoute = $selectedRoute->getProperties(); - /** @var PropertyFormatter $propertyFormatter */ - $propertyFormatter = $this->getService('property_formatter'); - - $propertyFormatter->displayData($output, $selectedRoute, $input->getOption('property')); + $this->propertyFormatter->displayData($output, $selectedRoute, $input->getOption('property')); return 0; } diff --git a/src/Command/Route/RouteListCommand.php b/src/Command/Route/RouteListCommand.php index 4e61286f21..83b3d88a18 100644 --- a/src/Command/Route/RouteListCommand.php +++ b/src/Command/Route/RouteListCommand.php @@ -1,60 +1,70 @@ */ + private array $tableHeader = [ 'route' => 'Route', 'type' => 'Type', 'to' => 'To', 'url' => 'URL', ]; - private $defaultColumns = ['route', 'type', 'to']; + /** @var string[] */ + private array $defaultColumns = ['route', 'type', 'to']; + public function __construct(private readonly Api $api, private readonly Config $config, private readonly Io $io, private readonly Selector $selector, private readonly Table $table) + { + parent::__construct(); + } - /** - * {@inheritdoc} - */ - protected function configure() + protected function configure(): void { $this - ->setName('route:list') - ->setAliases(['routes']) - ->setDescription('List all routes for an environment') ->addArgument('environment', InputArgument::OPTIONAL, 'The environment ID') ->addOption('refresh', null, InputOption::VALUE_NONE, 'Bypass the cache of routes'); $this->setHiddenAliases(['environment:routes']); Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); - $this->addProjectOption() - ->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { // Allow override via PLATFORM_ROUTES. - $prefix = $this->config()->get('service.env_prefix'); + $prefix = $this->config->getStr('service.env_prefix'); + $selection = null; if (getenv($prefix . 'ROUTES') && !LocalHost::conflictsWithCommandLineOptions($input, $prefix)) { - $this->debug('Reading routes from environment variable ' . $prefix . 'ROUTES'); - $decoded = json_decode(base64_decode(getenv($prefix . 'ROUTES'), true), true); + $this->io->debug('Reading routes from environment variable ' . $prefix . 'ROUTES'); + $decoded = json_decode((string) base64_decode(getenv($prefix . 'ROUTES'), true), true); if (!is_array($decoded)) { throw new \RuntimeException('Failed to decode: ' . $prefix . 'ROUTES'); } $routes = Route::fromVariables($decoded); $fromEnv = true; } else { - $this->debug('Reading routes from the deployments API'); - $this->validateInput($input); - $environment = $this->getSelectedEnvironment(); - $deployment = $this->api()->getCurrentDeployment($environment, $input->getOption('refresh')); + $this->io->debug('Reading routes from the deployments API'); + $selection = $this->selector->getSelection($input); + $deployment = $this->api->getCurrentDeployment($selection->getEnvironment(), $input->getOption('refresh')); $routes = Route::fromDeploymentApi($deployment->routes); $fromEnv = false; } @@ -64,9 +74,6 @@ protected function execute(InputInterface $input, OutputInterface $output) return 0; } - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - $rows = []; foreach ($routes as $route) { $row = []; @@ -77,26 +84,25 @@ protected function execute(InputInterface $input, OutputInterface $output) $rows[] = $row; } - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { if ($fromEnv) { $this->stdErr->writeln('Routes in the ' . $prefix . 'ROUTES environment variable:'); - } - if (isset($environment) && !$fromEnv) { + } else { $this->stdErr->writeln(sprintf( 'Routes on the project %s, environment %s:', - $this->api()->getProjectLabel($this->getSelectedProject()), - $this->api()->getEnvironmentLabel($environment) + $this->api->getProjectLabel($selection->getProject()), + $this->api->getEnvironmentLabel($selection->getEnvironment()), )); } } - $table->render($rows, $this->tableHeader, $this->defaultColumns); + $this->table->render($rows, $this->tableHeader, $this->defaultColumns); - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf( 'To view a single route, run: %s route:get ', - $this->config()->get('application.executable') + $this->config->getStr('application.executable'), )); } diff --git a/src/Command/RuntimeOperation/ListCommand.php b/src/Command/RuntimeOperation/ListCommand.php index 5c49ea0e20..0334eb9808 100644 --- a/src/Command/RuntimeOperation/ListCommand.php +++ b/src/Command/RuntimeOperation/ListCommand.php @@ -1,12 +1,19 @@ 'Service', 'name' => 'Operation name', 'start' => 'Start command', 'stop' => 'Stop command', 'role' => 'Role']; - private $defaultColumns = ['service', 'name', 'start']; + /** @var array */ + private array $tableHeader = ['service' => 'Service', 'name' => 'Operation name', 'start' => 'Start command', 'stop' => 'Stop command', 'role' => 'Role']; + /** @var string[] */ + private array $defaultColumns = ['service', 'name', 'start']; + public function __construct(private readonly Api $api, private readonly Config $config, private readonly Selector $selector, private readonly Table $table) + { + parent::__construct(); + } - protected function configure() + protected function configure(): void { - $this->setName('operation:list') - ->setAliases(['ops']) - ->setDescription('List runtime operations on an environment') + $this ->addOption('full', null, InputOption::VALUE_NONE, 'Do not limit the length of command to display. The default limit is ' . self::COMMAND_MAX_LENGTH . ' lines.'); - $this->addProjectOption(); - $this->addEnvironmentOption(); - $this->addAppOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->selector->addAppOption($this->getDefinition()); + $this->addCompleter($this->selector); $this->addOption('worker', null, InputOption::VALUE_REQUIRED, 'A worker name'); Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->chooseEnvFilter = $this->filterEnvsMaybeActive(); - $this->validateInput($input); - $deployment = $this->api()->getCurrentDeployment($this->getSelectedEnvironment()); + $selection = $this->selector->getSelection($input, new SelectorConfig(chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive())); + $deployment = $this->api->getCurrentDeployment($selection->getEnvironment()); // Fetch a list of operations grouped by service name, either for one // service or all of the services in an environment. try { if ($input->getOption('app') || $input->getOption('worker')) { - $selectedApp = $this->selectRemoteContainer($input); + $selectedApp = $selection->getRemoteContainer(); $operations = [ $selectedApp->getName() => $selectedApp->getRuntimeOperations(), ]; @@ -54,7 +66,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $selectedApp = null; $operations = $deployment->getRuntimeOperations(); } - } catch (OperationUnavailableException $e) { + } catch (OperationUnavailableException) { throw new ApiFeatureMissingException('This project does not support runtime operations.'); } @@ -77,45 +89,42 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(''); $this->stdErr->writeln("Runtime operations can be configured in the application's YAML definition."); - if ($this->config()->has('service.runtime_operations_help_url')) { + if ($this->config->has('service.runtime_operations_help_url')) { $this->stdErr->writeln(''); - $this->stdErr->writeln('For more information see: ' . $this->config()->get('service.runtime_operations_help_url')); + $this->stdErr->writeln('For more information see: ' . $this->config->getStr('service.runtime_operations_help_url')); } return 0; } - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { if ($selectedApp !== null) { $this->stdErr->writeln(sprintf( 'Runtime operations on the environment %s, app %s:', - $this->api()->getEnvironmentLabel($this->getSelectedEnvironment()), - $selectedApp->getName() + $this->api->getEnvironmentLabel($selection->getEnvironment()), + $selectedApp->getName(), )); } else { $this->stdErr->writeln(sprintf( 'Runtime operations on the environment %s:', - $this->api()->getEnvironmentLabel($this->getSelectedEnvironment()) + $this->api->getEnvironmentLabel($selection->getEnvironment()), )); } } - $table->render($rows, $this->tableHeader, $this->defaultColumns); + $this->table->render($rows, $this->tableHeader, $this->defaultColumns); - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->stdErr->writeln(''); - $this->stdErr->writeln(\sprintf('To run an operation, use: %s operation:run [operation]', $this->config()->get('application.executable'))); + $this->stdErr->writeln(\sprintf('To run an operation, use: %s operation:run [operation]', $this->config->getStr('application.executable'))); } return 0; } - private function truncateCommand($cmd) + private function truncateCommand(string $cmd): string { - $lines = \preg_split('/\r?\n/', $cmd); + $lines = (array) \preg_split('/\r?\n/', $cmd); if (count($lines) > self::COMMAND_MAX_LENGTH) { return trim(implode("\n", array_slice($lines, 0, self::COMMAND_MAX_LENGTH))) . "\n# ..."; } diff --git a/src/Command/RuntimeOperation/RunCommand.php b/src/Command/RuntimeOperation/RunCommand.php index adb65ba648..08495c37a2 100644 --- a/src/Command/RuntimeOperation/RunCommand.php +++ b/src/Command/RuntimeOperation/RunCommand.php @@ -1,10 +1,19 @@ setName('operation:run') - ->setDescription('Run an operation on the environment') + $this ->addArgument('operation', InputArgument::OPTIONAL, 'The operation name'); - $this->addProjectOption(); - $this->addEnvironmentOption(); - $this->addAppOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->selector->addAppOption($this->getDefinition()); + $this->addCompleter($this->selector); $this->addOption('worker', null, InputOption::VALUE_REQUIRED, 'A worker name'); - $this->addWaitOptions(); + $this->activityMonitor->addWaitOptions($this->getDefinition()); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->chooseEnvFilter = $this->filterEnvsMaybeActive(); - $this->validateInput($input); + $selection = $this->selector->getSelection($input, new SelectorConfig(chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive())); - $environment = $this->getSelectedEnvironment(); - $deployment = $this->api()->getCurrentDeployment($environment); + $environment = $selection->getEnvironment(); + $deployment = $this->api->getCurrentDeployment($environment); try { if ($input->getOption('app') || $input->getOption('worker')) { - $selectedApp = $this->selectRemoteContainer($input); + $selectedApp = $selection->getRemoteContainer(); $appName = $selectedApp->getName(); $operations = [ $selectedApp->getName() => $selectedApp->getRuntimeOperations(), @@ -47,7 +60,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $appName = null; $operations = $deployment->getRuntimeOperations(); } - } catch (OperationUnavailableException $e) { + } catch (OperationUnavailableException) { throw new ApiFeatureMissingException('This project does not support runtime operations.'); } @@ -57,9 +70,6 @@ protected function execute(InputInterface $input, OutputInterface $output) return 0; } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $operationName = $input->getArgument('operation'); if (!$operationName) { if (!$input->isInteractive()) { @@ -79,7 +89,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } } ksort($choices, SORT_NATURAL); - $operationName = $questionHelper->choose($choices, 'Enter a number to choose an operation to run:', null, false); + $operationName = $this->questionHelper->choose($choices, 'Enter a number to choose an operation to run:', null, false); $appName = $appNamesByOperationName[$operationName]; } else { $found = false; @@ -93,12 +103,12 @@ protected function execute(InputInterface $input, OutputInterface $output) } if (!$found) { if ($appName !== null) { - $this->stdErr->writeln(sprintf('The runtime operation %s was not found on the environment %s, app %s.', $operationName, $this->api()->getEnvironmentLabel($environment, 'comment'), $appName)); + $this->stdErr->writeln(sprintf('The runtime operation %s was not found on the environment %s, app %s.', $operationName, $this->api->getEnvironmentLabel($environment, 'comment'), $appName)); } else { - $this->stdErr->writeln(sprintf('The runtime operation %s was not found on the environment %s.', $operationName, $this->api()->getEnvironmentLabel($environment, 'comment'))); + $this->stdErr->writeln(sprintf('The runtime operation %s was not found on the environment %s.', $operationName, $this->api->getEnvironmentLabel($environment, 'comment'))); } $this->stdErr->writeln(''); - $this->stdErr->writeln(sprintf('To list operations, run: %s ops', $this->config()->get('application.executable'))); + $this->stdErr->writeln(sprintf('To list operations, run: %s ops', $this->config->getStr('application.executable'))); return 1; } } @@ -108,21 +118,20 @@ protected function execute(InputInterface $input, OutputInterface $output) } else { $this->stdErr->writeln(\sprintf('Running operation %s on the environment %s', $operationName, $appName)); } - if (!$questionHelper->confirm('Are you sure you want to continue?')) { + if (!$this->questionHelper->confirm('Are you sure you want to continue?')) { return 1; } try { $result = $deployment->execRuntimeOperation($operationName, $appName); - } catch (OperationUnavailableException $e) { + } catch (OperationUnavailableException) { throw new ApiFeatureMissingException('This project does not support runtime operations.'); } $success = true; - if ($this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $monitor */ - $monitor = $this->getService('activity_monitor'); - $success = $monitor->waitMultiple($result->getActivities(), $this->getSelectedProject()); + if ($this->activityMonitor->shouldWait($input)) { + $monitor = $this->activityMonitor; + $success = $monitor->waitMultiple($result->getActivities(), $selection->getProject()); } return $success ? 0 : 1; diff --git a/src/Command/Self/SelfBuildCommand.php b/src/Command/Self/SelfBuildCommand.php index f04565dc97..e297437e52 100644 --- a/src/Command/Self/SelfBuildCommand.php +++ b/src/Command/Self/SelfBuildCommand.php @@ -1,35 +1,46 @@ setName('self:build') - ->setDescription('Build a new package of the CLI') ->addOption('key', null, InputOption::VALUE_REQUIRED, 'The path to a private key') - ->addOption('output', null, InputOption::VALUE_REQUIRED, 'The output filename', $this->config()->get('application.executable') . '.phar') + ->addOption('output', null, InputOption::VALUE_REQUIRED, 'The output filename', $this->config->getStr('application.executable') . '.phar') ->addOption('replace-version', null, InputOption::VALUE_OPTIONAL, 'Replace the version number in config.yaml') ->addOption('no-composer-rebuild', null, InputOption::VALUE_NONE, 'Skip rebuilding Composer dependencies'); } - public function isEnabled() + public function isEnabled(): bool { // You can't build a Phar from another Phar. return !extension_loaded('Phar') || !\Phar::running(false); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { if (!file_exists(CLI_ROOT . '/vendor')) { $this->stdErr->writeln('Directory not found: ' . CLI_ROOT . '/vendor'); @@ -37,11 +48,8 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - /** @var \Platformsh\Cli\Service\Filesystem $fs */ - $fs = $this->getService('fs'); - $outputFilename = $input->getOption('output'); - if ($outputFilename && !$fs->canWrite($outputFilename)) { + if ($outputFilename && !$this->filesystem->canWrite($outputFilename)) { $this->stdErr->writeln("Not writable: $outputFilename"); return 1; } @@ -54,20 +62,15 @@ protected function execute(InputInterface $input, OutputInterface $output) $boxConfig = []; - /** @var \Platformsh\Cli\Service\Shell $shell */ - $shell = $this->getService('shell'); - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - - $version = $this->config()->getVersion(); + $version = $this->config->getVersion(); if ($input->getOption('replace-version')) { $version = $input->getOption('replace-version'); } else { - $tag = $shell->execute(['git', 'describe', '--tags'], CLI_ROOT, false); + $tag = $this->shell->execute(['git', 'describe', '--tags'], CLI_ROOT); if ($tag !== false) { $version = $tag; } - $version = $questionHelper->askInput('Version', $version); + $version = $this->questionHelper->askInput('Version', $version); } $boxConfig['replacements']['version-placeholder'] = $version; @@ -76,7 +79,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } if ($outputFilename) { - $boxConfig['output'] = $fs->makePathAbsolute($outputFilename); + $boxConfig['output'] = $this->filesystem->makePathAbsolute($outputFilename); } else { // Default output: cli-VERSION.phar in the current directory. $boxConfig['output'] = getcwd() . '/cli-' . $version . '.phar'; @@ -87,7 +90,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } if (file_exists($phar)) { - if (!$questionHelper->confirm("File exists: $phar. Overwrite?")) { + if (!$this->questionHelper->confirm("File exists: $phar. Overwrite?")) { return 1; } } @@ -97,9 +100,9 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln('If this fails, you may need to run "composer install" manually.'); // Wipe the vendor directory to be extra sure. - $shell->execute(['rm', '-rf', 'vendor'], CLI_ROOT, false); + $this->shell->execute(['rm', '-rf', 'vendor'], CLI_ROOT); - $shell->execute([ + $this->shell->execute([ 'composer', 'install', '--classmap-authoritative', @@ -109,7 +112,7 @@ protected function execute(InputInterface $input, OutputInterface $output) ], CLI_ROOT, true, false); // Install Box. - $shell->execute([ + $this->shell->execute([ 'composer', 'install', '--no-interaction', @@ -117,6 +120,9 @@ protected function execute(InputInterface $input, OutputInterface $output) ], CLI_ROOT . DIRECTORY_SEPARATOR . 'vendor-bin' . DIRECTORY_SEPARATOR . 'box', true, false); } + $this->stdErr->writeln('Warming application caches'); + Application::warmCaches(); + $boxArgs = [CLI_ROOT . '/vendor-bin/box/vendor/bin/box', 'compile', '--no-interaction']; if ($output->isVeryVerbose()) { $boxArgs[] = '-vvv'; @@ -127,7 +133,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } // Create a temporary box.json file for this build. - $originalConfig = json_decode(file_get_contents(CLI_ROOT . '/box.json'), true); + $originalConfig = json_decode((string) file_get_contents(CLI_ROOT . '/box.json'), true); $boxConfig = array_merge($originalConfig, $boxConfig); $boxConfig['base-path'] = CLI_ROOT; $tmpJson = tempnam(sys_get_temp_dir(), 'cli-box-'); @@ -135,7 +141,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $boxArgs[] = '--config=' . $tmpJson; $this->stdErr->writeln('Building Phar package using Box'); - $shell->execute($boxArgs, CLI_ROOT, true, false); + $this->shell->mustExecute($boxArgs, dir: CLI_ROOT, quiet: false); // Clean up the temporary file. if (!empty($tmpJson)) { @@ -154,7 +160,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln('The package was built successfully'); $output->writeln($phar); $this->stdErr->writeln([ - sprintf('Size: %s', FormatterHelper::formatMemory($size)), + sprintf('Size: %s', FormatterHelper::formatMemory((int) $size)), sprintf('SHA-1: %s', $sha1), sprintf('SHA-256: %s', $sha256), sprintf('Version: %s', $version), @@ -168,7 +174,7 @@ protected function execute(InputInterface $input, OutputInterface $output) * * @return bool */ - private function checkInstallerFile() + private function checkInstallerFile(): bool { $installerFile = CLI_ROOT . '/dist/installer.php'; $installerContents = \file_get_contents($installerFile); @@ -178,22 +184,23 @@ private function checkInstallerFile() } $start = "/* START_CONFIG */"; $end = "/* END_CONFIG */"; - $startPos = \strpos($installerContents, $start) + \strlen($start); + $commentStart = \strpos($installerContents, $start); + $startPos = $commentStart ? $commentStart + \strlen($start) : false; $endPos = \strpos($installerContents, $end); if ($startPos === false || $endPos === false || $endPos < $startPos) { $this->stdErr->writeln('Failed to locate config in installer file: ' . $installerFile . ''); return false; } $newConfig = \var_export([ - 'envPrefix' => $this->config()->get('application.env_prefix'), - 'manifestUrl' => $this->config()->get('application.manifest_url'), - 'configDir' => $this->config()->get('application.user_config_dir'), - 'executable' => $this->config()->get('application.executable'), - 'cliName' => $this->config()->get('application.name'), - 'userAgent' => $this->config()->get('application.slug'), - 'serviceEnvPrefix' => $this->config()->get('service.env_prefix'), - 'migratePrompt' => $this->config()->getWithDefault('migrate.prompt', false), - 'migrateDocsUrl' => $this->config()->getWithDefault('migrate.docs_url', ''), + 'envPrefix' => $this->config->getStr('application.env_prefix'), + 'manifestUrl' => $this->config->getStr('application.manifest_url'), + 'configDir' => $this->config->getStr('application.user_config_dir'), + 'executable' => $this->config->getStr('application.executable'), + 'cliName' => $this->config->getStr('application.name'), + 'userAgent' => $this->config->getStr('application.slug'), + 'serviceEnvPrefix' => $this->config->getStr('service.env_prefix'), + 'migratePrompt' => $this->config->getBool('migrate.prompt'), + 'migrateDocsUrl' => $this->config->getStr('migrate.docs_url'), ], true); $newContents = \substr($installerContents, 0, $startPos) . $newConfig . \substr($installerContents, $endPos); if ($newContents !== $installerContents) { diff --git a/src/Command/Self/SelfConfigCommand.php b/src/Command/Self/SelfConfigCommand.php index e860a17f0d..637fa4bed3 100644 --- a/src/Command/Self/SelfConfigCommand.php +++ b/src/Command/Self/SelfConfigCommand.php @@ -1,30 +1,35 @@ setName('self:config') - ->setDescription('Read CLI config') ->addArgument('value', InputArgument::OPTIONAL, 'Read a specific config value'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - /** @var PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - $formatter->displayData($output, $this->config()->getAll(), $input->getArgument('value')); + $this->propertyFormatter->displayData($output, $this->config->getAll(), $input->getArgument('value')); return 0; } } diff --git a/src/Command/Self/SelfInstallCommand.php b/src/Command/Self/SelfInstallCommand.php index bdae30ded8..68920424ef 100644 --- a/src/Command/Self/SelfInstallCommand.php +++ b/src/Command/Self/SelfInstallCommand.php @@ -1,44 +1,58 @@ setName('self:install') - ->setDescription('Install or update CLI configuration files') + $this ->addOption('shell-type', null, InputOption::VALUE_REQUIRED, 'The shell type for autocompletion (bash or zsh)'); $this->setHiddenAliases(['local:install']); - $cliName = $this->config()->get('application.name'); - $this->setHelp(<<config->getStr('application.name'); + $this->setHelp( + <<config()->getUserConfigDir(); + $configDir = $this->config->getUserConfigDir(); $this->stdErr->write('Copying resource files...'); $requiredFiles = [ 'shell-config.rc' => 'shell-config.tmpl.rc', 'shell-config-bash.rc' => 'shell-config-bash.tmpl.rc', ]; - if ($this->config()->get('application.executable') === 'platform') { + if ($this->config->getStr('application.executable') === 'platform') { $requiredFiles['shell-config-bash.rc'] = 'shell-config-bash-direct.tmpl.rc'; } $fs = new \Symfony\Component\Filesystem\Filesystem(); @@ -51,9 +65,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $contents = \preg_replace('/^##[^\n]*\n/m', '', $contents); // Replace configuration keys inside double curly brackets with // their values. - $contents = \preg_replace_callback('/\{\{ ?([a-z\d_.-]+) ?}}/', function ($matches) { - return $this->config()->get($matches[1]); - }, $contents); + $contents = \preg_replace_callback('/\{\{ ?([a-z\d_.-]+) ?}}/', fn($matches) => $this->config->getStr($matches[1]), (string) $contents); $fs->dumpFile($configDir . DIRECTORY_SEPARATOR . $destFile, $contents); } } catch (\Exception $e) { @@ -69,14 +81,14 @@ protected function execute(InputInterface $input, OutputInterface $output) if (OsUtil::isWindows()) { $this->stdErr->write('Creating .bat executable...'); $binDir = $configDir . DIRECTORY_SEPARATOR . 'bin'; - $binTarget = $this->config()->get('application.executable'); - $batDestination = $binDir . DIRECTORY_SEPARATOR . $this->config()->get('application.executable') . '.bat'; + $binTarget = $this->config->getStr('application.executable'); + $batDestination = $binDir . DIRECTORY_SEPARATOR . $this->config->getStr('application.executable') . '.bat'; $fs->dumpFile($batDestination, $this->generateBatContents($binTarget)); $this->stdErr->writeln(' done'); $this->stdErr->writeln(''); } - $manager = new Manager($this->config()); + $manager = new Manager($this->config); if ($manager->isSupported()) { $this->stdErr->write('Installing credential helper...'); if ($manager->isInstalled()) { @@ -96,29 +108,26 @@ protected function execute(InputInterface $input, OutputInterface $output) $shellType = $input->getOption('shell-type'); if ($shellType === null && getenv('SHELL') !== false) { $shellType = str_replace('.exe', '', basename(getenv('SHELL'))); - $this->debug('Detected shell type: ' . $shellType); + $this->io->debug('Detected shell type: ' . $shellType); } $this->stdErr->write('Setting up autocompletion...'); try { - $args = [ - '--generate-hook' => true, - '--program' => $this->config()->get('application.executable'), - ]; + $args = []; if ($shellType) { - $args['--shell-type'] = $shellType; + $args['shell'] = $shellType; } $buffer = new BufferedOutput(); - $exitCode = $this->runOtherCommand('_completion', $args, $buffer); + $exitCode = $this->subCommandRunner->run('completion', $args, $buffer); if ($exitCode === 0 && ($autoCompleteHook = $buffer->fetch())) { $fs->dumpFile($configDir . '/autocompletion.sh', $autoCompleteHook); $this->stdErr->writeln(' done'); } - } catch (\Exception $e) { + } catch (\Throwable $e) { // If stdout is not a terminal, then we tried but // autocompletion probably isn't needed at all, as we are in the // context of some kind of automated build. So ignore the error. - if (!$this->isTerminal(STDOUT)) { + if (!$this->io->isTerminal(STDOUT)) { $this->stdErr->writeln(' skipped (not a terminal)'); } elseif ($shellType === null) { $this->stdErr->writeln(' skipped (unsupported shell)'); @@ -133,22 +142,21 @@ protected function execute(InputInterface $input, OutputInterface $output) } $this->stdErr->writeln(''); - $shellConfigOverrideVar = $this->config()->get('application.env_prefix') . 'SHELL_CONFIG_FILE'; + $shellConfigOverrideVar = $this->config->getStr('application.env_prefix') . 'SHELL_CONFIG_FILE'; $shellConfigOverride = getenv($shellConfigOverrideVar); if ($shellConfigOverride === '') { - $this->debug(sprintf('Shell config detection disabled via %s', $shellConfigOverrideVar)); + $this->io->debug(sprintf('Shell config detection disabled via %s', $shellConfigOverrideVar)); $shellConfigFile = false; } elseif ($shellConfigOverride !== false) { - /** @var \Platformsh\Cli\Service\Filesystem $fsService */ - $fsService = $this->getService('fs'); + $fsService = $this->filesystem; if (!$fsService->canWrite($shellConfigOverride)) { throw new \RuntimeException(sprintf( 'File not writable: %s (defined in %s)', $shellConfigOverride, - $shellConfigOverrideVar + $shellConfigOverrideVar, )); } - $this->debug(sprintf('Shell config file specified via %s', $shellConfigOverrideVar)); + $this->io->debug(sprintf('Shell config file specified via %s', $shellConfigOverrideVar)); $shellConfigFile = $shellConfigOverride; } else { $shellConfigFile = $this->findShellConfigFile($shellType); @@ -168,19 +176,16 @@ protected function execute(InputInterface $input, OutputInterface $output) $pathParts = $path !== false ? array_unique(array_filter(explode(';', $path))) : []; if ($path !== false && !empty($pathParts)) { $newPath = implode(';', $pathParts) . ';' . $binDir; - /** @var \Platformsh\Cli\Service\Shell $shell */ - $shell = $this->getService('shell'); + $shell = $this->shell; $setPathCommand = 'setx PATH ' . OsUtil::escapeShellArg($newPath); - if ($shell->execute($setPathCommand, null, false, true, [], 10) !== false) { + if ($shell->execute($setPathCommand, timeout: 10) !== false) { $this->markSelfInstalled($configDir); $this->stdErr->writeln($this->getRunAdvice('', $binDir, true, true)); return 0; } } - $this->stdErr->writeln(sprintf( - 'To set up the CLI, add this directory to your Path environment variable:' - )); + $this->stdErr->writeln('To set up the CLI, add this directory to your Path environment variable:'); $this->stdErr->writeln(sprintf('%s', $binDir)); $this->stdErr->writeln('Then open a new terminal to continue.'); @@ -201,42 +206,39 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(''); } - $configDirRelative = $this->config()->getUserConfigDir(false); + $configDirRelative = $this->config->getUserConfigDir(false); $rcDestination = $configDirRelative . DIRECTORY_SEPARATOR . 'shell-config.rc'; - $suggestedShellConfig = 'HOME=${HOME:-' . escapeshellarg($this->config()->getHomeDirectory()) . '}'; + $suggestedShellConfig = 'HOME=${HOME:-' . escapeshellarg($this->config->getHomeDirectory()) . '}'; $suggestedShellConfig .= PHP_EOL . sprintf( 'export PATH=%s:"$PATH"', - '"$HOME/"' . escapeshellarg($configDirRelative . '/bin') + '"$HOME/"' . escapeshellarg($configDirRelative . '/bin'), ); $suggestedShellConfig .= PHP_EOL . sprintf( 'if [ -f %1$s ]; then . %1$s; fi', - '"$HOME/"' . escapeshellarg($rcDestination) + '"$HOME/"' . escapeshellarg($rcDestination), ); - if (strpos($currentShellConfig, $suggestedShellConfig) !== false) { + if ($shellConfigFile !== false && str_contains($currentShellConfig, $suggestedShellConfig)) { $this->stdErr->writeln('Already configured: ' . $this->getShortPath($shellConfigFile) . ''); $this->stdErr->writeln(''); $this->markSelfInstalled($configDir); $this->stdErr->writeln($this->getRunAdvice($shellConfigFile, $configDir . '/bin')); return 0; } - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); $modify = false; $create = false; if ($shellConfigFile !== false) { $confirmText = file_exists($shellConfigFile) ? 'Do you want to update the file automatically?' : 'Do you want to create the file automatically?'; - if ($questionHelper->confirm($confirmText)) { + if ($this->questionHelper->confirm($confirmText)) { $modify = true; $create = !file_exists($shellConfigFile); } $this->stdErr->writeln(''); } - $appName = (string) $this->config()->get('application.name'); + $appName = $this->config->getStr('application.name'); $begin = '# BEGIN SNIPPET: ' . $appName . ' configuration' . PHP_EOL; $end = ' # END SNIPPET'; $beginPattern = '/^' . preg_quote('# BEGIN SNIPPET:') . '[^\n]*' . preg_quote($appName) . '[^\n]*$/m'; @@ -245,11 +247,11 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($shellConfigFile !== false) { $this->stdErr->writeln(sprintf( 'To set up the CLI, add the following lines to: %s', - $shellConfigFile + $shellConfigFile, )); } else { $this->stdErr->writeln( - 'To set up the CLI, add the following lines to your shell configuration file:' + 'To set up the CLI, add the following lines to your shell configuration file:', ); } @@ -287,27 +289,22 @@ protected function execute(InputInterface $input, OutputInterface $output) * * @param string $configDir */ - private function markSelfInstalled($configDir) + private function markSelfInstalled(string $configDir): void { $filename = $configDir . DIRECTORY_SEPARATOR . self::INSTALLED_FILENAME; if (!file_exists($filename)) { $fs = new \Symfony\Component\Filesystem\Filesystem(); - $fs->dumpFile($filename, json_encode(['installed_at' => date('c')])); + $fs->dumpFile($filename, (string) json_encode(['installed_at' => date('c')])); } } /** - * @param string $shellConfigFile - * @param string $binDir - * @param bool|null $inPath - * @param bool $newTerminal - * * @return string[] */ - private function getRunAdvice($shellConfigFile, $binDir, $inPath = null, $newTerminal = false) + private function getRunAdvice(string $shellConfigFile, string $binDir, ?bool $inPath = null, bool $newTerminal = false): array { $advice = [ - sprintf('To use the %s,%s run:', $this->config()->get('application.name'), $newTerminal ? ' open a new terminal, and' : '') + sprintf('To use the %s,%s run:', $this->config->getStr('application.name'), $newTerminal ? ' open a new terminal, and' : ''), ]; if ($inPath === null) { $inPath = $this->inPath($binDir); @@ -317,19 +314,15 @@ private function getRunAdvice($shellConfigFile, $binDir, $inPath = null, $newTer $sourceAdvice .= ' # (make sure your shell does this by default)'; $advice[] = $sourceAdvice; } - $advice[] = sprintf(' %s', $this->config()->get('application.executable')); + $advice[] = sprintf(' %s', $this->config->getStr('application.executable')); return $advice; } /** - * Check if a directory is in the PATH. - * - * @param string $dir - * - * @return bool + * Checks if a directory is in the PATH. */ - private function inPath($dir) + private function inPath(string $dir): bool { $PATH = getenv('PATH'); $realpath = realpath($dir); @@ -349,15 +342,15 @@ private function inPath($dir) * * @return string */ - private function formatSourceArg($filename) + private function formatSourceArg(string $filename): string { $arg = $filename; // Replace the home directory with ~, if not on Windows. if (DIRECTORY_SEPARATOR !== '\\') { $realpath = realpath($filename); - $homeDir = $this->config()->getHomeDirectory(); - if ($realpath && strpos($realpath, $homeDir) === 0) { + $homeDir = $this->config->getHomeDirectory(); + if ($realpath && str_starts_with($realpath, $homeDir)) { $arg = '~/' . ltrim(substr($realpath, strlen($homeDir)), '/'); } } @@ -374,19 +367,15 @@ private function formatSourceArg($filename) } /** - * Shorten a filename for display. - * - * @param string $filename - * - * @return string + * Shortens a filename for display. */ - private function getShortPath($filename) + private function getShortPath(string $filename): string { if (getcwd() === dirname($filename)) { return basename($filename); } - $homeDir = $this->config()->getHomeDirectory(); - if (strpos($filename, $homeDir) === 0) { + $homeDir = $this->config->getHomeDirectory(); + if (str_starts_with($filename, $homeDir)) { return str_replace($homeDir, '~', $filename); } @@ -396,18 +385,16 @@ private function getShortPath($filename) /** * Finds a shell configuration file for the user. * - * @param string|null $shellType The shell type. - * * @return string|false * The absolute path to a shell config file, or false on failure. */ - protected function findShellConfigFile($shellType) + protected function findShellConfigFile(string|null $shellType): string|false { // Special handling for the .environment file on Platform.sh environments. - $envPrefix = $this->config()->get('service.env_prefix'); + $envPrefix = $this->config->getStr('service.env_prefix'); if (getenv($envPrefix . 'PROJECT') !== false && getenv($envPrefix . 'APP_DIR') !== false - && getenv($envPrefix . 'APP_DIR') === $this->config()->getHomeDirectory()) { + && getenv($envPrefix . 'APP_DIR') === $this->config->getHomeDirectory()) { return getenv($envPrefix . 'APP_DIR') . '/.environment'; } @@ -431,10 +418,10 @@ protected function findShellConfigFile($shellType) } // Pick the first of the candidate files that already exists. - $homeDir = $this->config()->getHomeDirectory(); + $homeDir = $this->config->getHomeDirectory(); foreach ($candidates as $candidate) { if (file_exists($homeDir . DIRECTORY_SEPARATOR . $candidate)) { - $this->debug('Found existing config file: ' . $homeDir . DIRECTORY_SEPARATOR . $candidate); + $this->io->debug('Found existing config file: ' . $homeDir . DIRECTORY_SEPARATOR . $candidate); return $homeDir . DIRECTORY_SEPARATOR . $candidate; } @@ -448,15 +435,15 @@ protected function findShellConfigFile($shellType) // then create a new file based on the shell type. if ($shellType === 'bash') { if (OsUtil::isOsX()) { - $this->debug('OS X: defaulting to ~/.bash_profile'); + $this->io->debug('OS X: defaulting to ~/.bash_profile'); return $homeDir . DIRECTORY_SEPARATOR . '.bash_profile'; } - $this->debug('Defaulting to ~/.bashrc'); + $this->io->debug('Defaulting to ~/.bashrc'); return $homeDir . DIRECTORY_SEPARATOR . '.bashrc'; } elseif ($shellType === 'zsh') { - $this->debug('Defaulting to ~/.zshrc'); + $this->io->debug('Defaulting to ~/.zshrc'); return $homeDir . DIRECTORY_SEPARATOR . '.zshrc'; } @@ -468,12 +455,12 @@ protected function findShellConfigFile($shellType) * Indents and word-wraps a string. * * @param string $str - * @param int $indent - * @param int $width + * @param int $indent + * @param int $width * * @return string */ - private function indentAndWrap($str, $indent = 4, $width = 75) + private function indentAndWrap(string $str, int $indent = 4, int $width = 75): string { $spaces = str_repeat(' ', $indent); $wrapped = wordwrap($str, $width - $indent, PHP_EOL); @@ -488,11 +475,11 @@ private function indentAndWrap($str, $indent = 4, $width = 75) * * @return string */ - private function generateBatContents($binTarget) + private function generateBatContents(string $binTarget): string { - return "@ECHO OFF\r\n". - "setlocal DISABLEDELAYEDEXPANSION\r\n". - "SET BIN_TARGET=%~dp0/" . trim(OsUtil::escapeShellArg($binTarget), '"\'') . "\r\n". + return "@ECHO OFF\r\n" . + "setlocal DISABLEDELAYEDEXPANSION\r\n" . + "SET BIN_TARGET=%~dp0/" . trim(OsUtil::escapeShellArg($binTarget), '"\'') . "\r\n" . "php \"%BIN_TARGET%\" %*\r\n"; } } diff --git a/src/Command/Self/SelfReleaseCommand.php b/src/Command/Self/SelfReleaseCommand.php index 7cea343f91..62c7fe29c1 100644 --- a/src/Command/Self/SelfReleaseCommand.php +++ b/src/Command/Self/SelfReleaseCommand.php @@ -1,9 +1,19 @@ config()->get('application.github_repo'); - $defaultReleaseBranch = $this->config()->getWithDefault('application.release_branch', 'main'); + $defaultRepo = $this->config->getStr('application.github_repo'); + $defaultReleaseBranch = $this->config->getStr('application.release_branch'); - $this->setName('self:release') - ->setDescription('Build and release a new version') + $this ->addArgument('version', InputArgument::OPTIONAL, 'The new version number') ->addOption('phar', null, InputOption::VALUE_REQUIRED, 'The path to a newly built Phar file') ->addOption('repo', null, InputOption::VALUE_REQUIRED, 'The GitHub repository', $defaultRepo) @@ -33,39 +47,31 @@ protected function configure() ->addOption('allow-lower', null, InputOption::VALUE_NONE, 'Allow releasing with a lower version number than the last'); } - public function isEnabled() + public function isEnabled(): bool { - return $this->config()->has('application.github_repo') + return $this->config->has('application.github_repo') && (!extension_loaded('Phar') || !\Phar::running(false)); } /** * @throws \Exception */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - - /** @var \Platformsh\Cli\Service\Shell $shell */ - $shell = $this->getService('shell'); - - /** @var \Platformsh\Cli\Service\Git $git */ - $git = $this->getService('git'); - $git->setDefaultRepositoryDir(CLI_ROOT); + $this->git->setDefaultRepositoryDir(CLI_ROOT); $releaseBranch = $input->getOption('release-branch'); - if ($git->getCurrentBranch(CLI_ROOT, true) !== $releaseBranch) { + if ($this->git->getCurrentBranch(CLI_ROOT, true) !== $releaseBranch) { $this->stdErr->writeln('You must be on the ' . $releaseBranch . ' branch to make a release.'); return 1; } if (!$input->getOption('no-check-changes')) { - $gitStatus = $git->execute(['status', '--porcelain'], CLI_ROOT, true); + $gitStatus = $this->git->execute(['status', '--porcelain'], CLI_ROOT, true); if (is_string($gitStatus) && !empty($gitStatus)) { foreach (explode("\n", $gitStatus) as $statusLine) { - if (strpos($statusLine, ' config.yaml') === false) { + if (!str_contains($statusLine, ' config.yaml')) { $this->stdErr->writeln('There are uncommitted changes in Git. Cannot proceed.'); $this->stdErr->writeln('Use the --no-check-changes option to override this.'); @@ -75,8 +81,8 @@ protected function execute(InputInterface $input, OutputInterface $output) } } - if ($shell->commandExists('gh')) { - $process = $shell->executeCaptureProcess('gh auth status --show-token', null, true); + if ($this->shell->commandExists('gh')) { + $process = $this->shell->executeCaptureProcess('gh auth status --show-token', null, true); if (!preg_match('/Token: (\S+)/', $process->getOutput(), $matches)) { $this->stdErr->writeln('Unable to obtain a GitHub token.'); $this->stdErr->writeln('Log in to the GitHub CLI with: gh auth login'); @@ -92,12 +98,12 @@ protected function execute(InputInterface $input, OutputInterface $output) // Find the previous version number. if ($input->getOption('last-version')) { - $lastVersion = ltrim($input->getOption('last-version'), 'v'); + $lastVersion = ltrim((string) $input->getOption('last-version'), 'v'); $lastTag = 'v' . $lastVersion; $this->stdErr->writeln('Last version number: ' . $lastVersion . ''); } else { - $lastTag = $shell->execute(['git', 'describe', '--tags', '--abbrev=0'], CLI_ROOT, true); + $lastTag = $this->shell->mustExecute(['git', 'describe', '--tags', '--abbrev=0'], dir: CLI_ROOT); $lastVersion = ltrim($lastTag, 'v'); $this->stdErr->writeln('Last version number (from latest Git tag): ' . $lastVersion . ''); } @@ -116,7 +122,7 @@ protected function execute(InputInterface $input, OutputInterface $output) if (!$allowLower && version_compare($next, $lastVersion, '<=')) { throw new \InvalidArgumentException( 'The new version number must be greater than ' . $lastVersion - . "\n" . 'Use --allow-lower to skip this check.' + . "\n" . 'Use --allow-lower to skip this check.', ); } @@ -142,18 +148,18 @@ protected function execute(InputInterface $input, OutputInterface $output) $default = reset($nextVersions); $autoComplete = $nextVersions; } - $newVersion = $questionHelper->askInput('New version number', $default, $autoComplete, $validateNewVersion); + $newVersion = $this->questionHelper->askInput('New version number', $default, $autoComplete, $validateNewVersion); } // Set up GitHub API connection details. $http = new Client(); - $repo = $input->getOption('repo') ?: $this->config()->get('application.github_repo'); - $repoUrl = implode('/', array_map('rawurlencode', explode('/', $repo))); + $repo = $input->getOption('repo') ?: $this->config->getStr('application.github_repo'); + $repoUrl = implode('/', array_map('rawurlencode', explode('/', (string) $repo))); $repoApiUrl = 'https://api.github.com/repos/' . $repoUrl; $repoGitUrl = 'git@github.com:' . $repo . '.git'; // Check if the chosen version number already exists as a release. - $tagName = 'v' . ltrim($newVersion, 'v'); + $tagName = 'v' . ltrim((string) $newVersion, 'v'); $existsResponse = $http->get($repoApiUrl . '/releases/tags/' . $tagName, [ 'headers' => [ 'Authorization' => 'token ' . $gitHubToken, @@ -203,7 +209,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $latestItem = &$manifest[$key]; } if ($versionUtil->majorVersion($item['version']) === $versionUtil->majorVersion($newVersion)) { - if ($latestSameMajorItem || version_compare($item['version'], $latestSameMajorItem['version'], '>')) { + if ($latestSameMajorItem === null || version_compare($item['version'], $latestSameMajorItem['version'], '>')) { $latestSameMajorItem = &$manifest[$key]; } } @@ -230,11 +236,9 @@ protected function execute(InputInterface $input, OutputInterface $output) } // Confirm the release changelog. - list($changelogFilename, $changelog) = $this->getReleaseChangelog($lastTag, $tagName); + [$changelogFilename, $changelog] = $this->getReleaseChangelog($lastTag, $tagName); $questionText = "\nChangelog:\n\n" . $changelog . "\n\nIs this changelog correct?"; - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - if (!$questionHelper->confirm($questionText)) { + if (!$this->questionHelper->confirm($questionText)) { $this->stdErr->writeln('Update or delete the file ' . $changelogFilename . ' and re-run this command.'); return 1; @@ -242,8 +246,8 @@ protected function execute(InputInterface $input, OutputInterface $output) // Build a Phar file, if one doesn't already exist. if (!$pharFilename) { - $pharFilename = sys_get_temp_dir() . '/' . $this->config()->get('application.executable') . '.phar'; - $result = $this->runOtherCommand('self:build', [ + $pharFilename = sys_get_temp_dir() . '/' . $this->config->getStr('application.executable') . '.phar'; + $result = $this->subCommandRunner->run('self:build', [ '--output' => $pharFilename, '--yes' => true, '--replace-version' => $newVersion, @@ -257,12 +261,12 @@ protected function execute(InputInterface $input, OutputInterface $output) // Validate that the Phar file has the right version number. if ($pharFilename) { - $versionInPhar = $shell->execute([ + $versionInPhar = $this->shell->mustExecute([ (new PhpExecutableFinder())->find() ?: PHP_BINARY, $pharFilename, - '--version' - ], null, true); - if (strpos($versionInPhar, $newVersion) === false) { + '--version', + ]); + if (!str_contains($versionInPhar, (string) $newVersion)) { $this->stdErr->writeln('The file ' . $pharFilename . ' reports a different version: "' . $versionInPhar . '"'); return 1; @@ -270,10 +274,10 @@ protected function execute(InputInterface $input, OutputInterface $output) } // Construct the download URL (the public location of the Phar file). - $pharPublicFilename = $this->config()->get('application.executable') . '.phar'; - $download_url = str_replace('{tag}', $tagName, $this->config()->getWithDefault( + $pharPublicFilename = $this->config->getStr('application.executable') . '.phar'; + $download_url = str_replace('{tag}', $tagName, $this->config->getWithDefault( 'application.download_url', - 'https://github.com/' . $repoUrl . '/releases/download/{tag}/' . $pharPublicFilename + 'https://github.com/' . $repoUrl . '/releases/download/{tag}/' . $pharPublicFilename, )); // Construct the new manifest item details. @@ -294,19 +298,18 @@ protected function execute(InputInterface $input, OutputInterface $output) $result = file_put_contents($manifestFile, json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); if ($result !== false) { $this->stdErr->writeln('Updated manifest file: ' . $manifestFile); - } - else { + } else { $this->stdErr->writeln('Failed to update manifest file: ' . $manifestFile); return 1; } // Commit any changes to Git. - $gitStatus = $git->execute(['status', '--porcelain'], CLI_ROOT, true); + $gitStatus = $this->git->execute(['status', '--porcelain'], CLI_ROOT, true); if (is_string($gitStatus) && !empty($gitStatus)) { $this->stdErr->writeln('Committing changes to Git'); - $result = $shell->executeSimple('git commit --patch config.yaml dist/manifest.json --message ' . escapeshellarg('Release v' . $newVersion) . ' --edit', CLI_ROOT); + $result = $this->shell->executeSimple('git commit --patch config.yaml dist/manifest.json --message ' . escapeshellarg('Release v' . $newVersion) . ' --edit', CLI_ROOT); if ($result !== 0) { return $result; } @@ -314,14 +317,14 @@ protected function execute(InputInterface $input, OutputInterface $output) // Tag the current commit. $this->stdErr->writeln('Creating tag ' . $tagName . ''); - $git->execute(['tag', '--force', $tagName], CLI_ROOT, true); + $this->git->execute(['tag', '--force', $tagName], CLI_ROOT, true); // Push to GitHub. - if (!$questionHelper->confirm('Push changes to ' . $releaseBranch . ' branch on ' . $repoGitUrl . '?')) { + if (!$this->questionHelper->confirm('Push changes to ' . $releaseBranch . ' branch on ' . $repoGitUrl . '?')) { return 1; } - $shell->execute(['git', 'push', $repoGitUrl, 'HEAD:' . $releaseBranch], CLI_ROOT, true); - $shell->execute(['git', 'push', '--force', $repoGitUrl, $tagName], CLI_ROOT, true); + $this->shell->execute(['git', 'push', $repoGitUrl, 'HEAD:' . $releaseBranch], CLI_ROOT, true); + $this->shell->execute(['git', 'push', '--force', $repoGitUrl, $tagName], CLI_ROOT, true); // Upload a release to GitHub. $lastReleasePublicUrl = 'https://github.com/' . $repoUrl . '/releases/' . $lastTag; @@ -353,9 +356,9 @@ protected function execute(InputInterface $input, OutputInterface $output) ], 'debug' => $output->isDebug(), ]); - $release = $response->json(); + $release = (array) Utils::jsonDecode((string) $response->getBody(), true); $releaseUrl = $repoApiUrl . '/releases/' . $release['id']; - $uploadUrl = preg_replace('/\{.+?\}/', '', $release['upload_url']); + $uploadUrl = preg_replace('/\{.+?}/', '', (string) $release['upload_url']); // Upload the Phar to the GitHub release. $this->stdErr->writeln('Uploading the Phar file to the release'); @@ -397,7 +400,7 @@ protected function execute(InputInterface $input, OutputInterface $output) * * @return string[] The filename and the current changelog. */ - private function getReleaseChangelog($lastVersionTag, $newVersionTag) + private function getReleaseChangelog(string $lastVersionTag, string $newVersionTag): array { $filename = CLI_ROOT . '/release-changelog-' . $newVersionTag . '.md'; if (file_exists($filename)) { @@ -426,11 +429,9 @@ private function getReleaseChangelog($lastVersionTag, $newVersionTag) * * @return int|false */ - private function getTagDate($tagName) + private function getTagDate(string $tagName): int|false { - /** @var \Platformsh\Cli\Service\Git $git */ - $git = $this->getService('git'); - $date = $git->execute(['log', '-1', '--format=%aI', 'refs/tags/' . $tagName]); + $date = $this->git->execute(['log', '-1', '--format=%aI', 'refs/tags/' . $tagName]); return is_string($date) ? strtotime(trim($date)) : false; } @@ -442,18 +443,16 @@ private function getTagDate($tagName) * * @return bool */ - private function hasGitDifferences($since) + private function hasGitDifferences(string $since): bool { - /** @var \Platformsh\Cli\Service\Git $git */ - $git = $this->getService('git'); - $stat = $git->execute(['diff', '--numstat', $since . '...HEAD'], CLI_ROOT, true); + $stat = $this->git->execute(['diff', '--numstat', $since . '...HEAD'], CLI_ROOT, true); if (!is_string($stat)) { return false; } foreach (explode("\n", trim($stat)) as $line) { // Exclude config.yaml and dist/manifest.json from the check. - if (strpos($line, ' config.yaml') === false && strpos($line, ' dist/manifest.json') === false) { + if (!str_contains($line, ' config.yaml') && !str_contains($line, ' dist/manifest.json')) { return true; } } @@ -466,11 +465,9 @@ private function hasGitDifferences($since) * * @return string */ - private function getGitChangelog($since) + private function getGitChangelog(string $since): string { - /** @var \Platformsh\Cli\Service\Git $git */ - $git = $this->getService('git'); - $changelog = $git->execute([ + $changelog = $this->git->execute([ 'log', '--pretty=tformat:* %s%n%b', '--no-merges', @@ -478,15 +475,15 @@ private function getGitChangelog($since) '--grep=(Release v|\[skip changelog\])', '--perl-regexp', '--regexp-ignore-case', - $since . '...HEAD' + $since . '...HEAD', ], CLI_ROOT); if (!is_string($changelog)) { return ''; } - $changelog = preg_replace('/^[^\*\n]/m', ' $0', $changelog); - $changelog = preg_replace('/\n+\*/', "\n*", $changelog); - $changelog = trim($changelog); + $changelog = preg_replace('/^[^*\n]/m', ' $0', $changelog); + $changelog = preg_replace('/\n+\*/', "\n*", (string) $changelog); + $changelog = trim((string) $changelog); return $changelog; } diff --git a/src/Command/Self/SelfStatsCommand.php b/src/Command/Self/SelfStatsCommand.php index 463e1756eb..25a0612d30 100644 --- a/src/Command/Self/SelfStatsCommand.php +++ b/src/Command/Self/SelfStatsCommand.php @@ -1,41 +1,52 @@ */ + private array $tableHeader = ['Release', 'Date', 'Asset', 'Downloads']; - private $tableHeader = ['Release', 'Date', 'Asset', 'Downloads']; + public function __construct(private readonly Config $config, private readonly PropertyFormatter $propertyFormatter, private readonly Table $table) + { + parent::__construct(); + } - protected function configure() + protected function configure(): void { $this - ->setName('self:stats') - ->setDescription('View stats on GitHub package downloads') ->addOption('page', 'p', InputOption::VALUE_REQUIRED, 'Page number', 1) ->addOption('count', 'c', InputOption::VALUE_REQUIRED, 'Results per page (max: 100)', 20); Table::configureInput($this->getDefinition(), $this->tableHeader); PropertyFormatter::configureInput($this->getDefinition()); } - public function isEnabled() + public function isEnabled(): bool { - return $this->config()->has('application.github_repo'); + return $this->config->has('application.github_repo'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $repo = $this->config()->get('application.github_repo'); + $repo = $this->config->getStr('application.github_repo'); $repoUrl = implode('/', array_map('rawurlencode', explode('/', $repo))); - $releases = (new Client()) + $response = (new Client()) ->get('https://api.github.com/repos/' . $repoUrl . '/releases', [ 'headers' => [ 'Accept' => 'application/vnd.github.v3+json', @@ -44,35 +55,31 @@ protected function execute(InputInterface $input, OutputInterface $output) 'page' => (int) $input->getOption('page'), 'per_page' => (int) $input->getOption('count'), ], - ])->json(); + ]); + $releases = (array) Utils::jsonDecode((string) $response->getBody(), true); if (empty($releases)) { $this->stdErr->writeln('No releases found.'); return 1; } - - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); $rows = []; foreach ($releases as $release) { $row = []; $row[] = $release['name']; $time = !empty($release['published_at']) ? $release['published_at'] : $release['created_at']; - $row[] = $formatter->format($time, 'created_at'); + $row[] = $this->propertyFormatter->format($time, 'created_at'); if (!empty($release['assets'])) { foreach ($release['assets'] as $asset) { $row[] = $asset['name']; - $row[] = $formatter->format($asset['download_count']); + $row[] = $this->propertyFormatter->format($asset['download_count']); break; } } $rows[] = $row; } - $table->render($rows, $this->tableHeader); + $this->table->render($rows, $this->tableHeader); return 0; } diff --git a/src/Command/Self/SelfUpdateCommand.php b/src/Command/Self/SelfUpdateCommand.php index 39e3fa26b2..a634e831c2 100644 --- a/src/Command/Self/SelfUpdateCommand.php +++ b/src/Command/Self/SelfUpdateCommand.php @@ -1,20 +1,28 @@ setName('self:update') - ->setAliases(['update', 'up']) ->setHiddenAliases(['self-update']) - ->setDescription('Update the CLI to the latest version') ->addOption('no-major', null, InputOption::VALUE_NONE, 'Only update between minor or patch versions') ->addOption('unstable', null, InputOption::VALUE_NONE, 'Update to a new unstable version, if available') ->addOption('manifest', null, InputOption::VALUE_REQUIRED, 'Override the manifest file location') @@ -22,18 +30,15 @@ protected function configure() ->addOption('timeout', null, InputOption::VALUE_REQUIRED, 'A timeout for the version check', 30); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $manifestUrl = $input->getOption('manifest') ?: $this->config()->get('application.manifest_url'); - $currentVersion = $input->getOption('current-version') ?: $this->config()->getVersion(); + $manifestUrl = $input->getOption('manifest') ?: $this->config->getStr('application.manifest_url'); + $currentVersion = $input->getOption('current-version') ?: $this->config->getVersion(); + $this->selfUpdater->setAllowMajor(!$input->getOption('no-major')); + $this->selfUpdater->setAllowUnstable((bool) $input->getOption('unstable')); + $this->selfUpdater->setTimeout($input->getOption('timeout')); - /** @var \Platformsh\Cli\Service\SelfUpdater $cliUpdater */ - $cliUpdater = $this->getService('self_updater'); - $cliUpdater->setAllowMajor(!$input->getOption('no-major')); - $cliUpdater->setAllowUnstable((bool) $input->getOption('unstable')); - $cliUpdater->setTimeout($input->getOption('timeout')); - - $result = $cliUpdater->update($manifestUrl, $currentVersion); + $result = $this->selfUpdater->update($manifestUrl, $currentVersion); if ($result === '') { return 0; } @@ -46,12 +51,4 @@ protected function execute(InputInterface $input, OutputInterface $output) // ConsoleTerminateEvent), we exit directly now. exit(0); } - - /** - * {@inheritdoc} - */ - protected function checkUpdates() - { - // Don't check for updates automatically when running self-update. - } } diff --git a/src/Command/Server/ServerCommandBase.php b/src/Command/Server/ServerCommandBase.php index 74897b056d..3ddd4cb09e 100644 --- a/src/Command/Server/ServerCommandBase.php +++ b/src/Command/Server/ServerCommandBase.php @@ -1,6 +1,14 @@ >|null */ + private ?array $serverInfo = null; + + #[Required] + public function autowire(Config $config, LocalProject $localProject, Selector $selector, Shell $shell): void + { + $this->config = $config; + $this->localProject = $localProject; + $this->shell = $shell; + $this->selector = $selector; + } - public function isEnabled() + public function isEnabled(): bool { - return $this->config()->isExperimentEnabled('enable_local_server') + return $this->config->isExperimentEnabled('enable_local_server') && parent::isEnabled(); } /** - * Check whether another server is running for an app. - * - * @param string $appId - * @param string $projectRoot + * Checks whether another server is running for an app. * - * @return bool|array + * @return array|false */ - protected function isServerRunningForApp($appId, $projectRoot) + protected function isServerRunningForApp(string $appId, string $projectRoot): array|false { foreach ($this->getServerInfo() as $address => $server) { if ($server['appId'] === $appId && $server['projectRoot'] === $projectRoot) { @@ -49,25 +68,20 @@ protected function isServerRunningForApp($appId, $projectRoot) * * @return bool */ - protected function isProcessDead($pid) + private function isProcessDead(int $pid): bool { - /** @noinspection PhpComposerExtensionStubsInspection */ return function_exists('posix_kill') && !posix_kill($pid, 0); } /** - * Check whether another server is running at an address. - * - * @param string $address - * - * @return bool|int + * Checks whether another server is running at an address. */ - protected function isServerRunningForAddress($address) + protected function isServerRunningForAddress(string $address): bool|int { $pidFile = $this->getPidFile($address); $serverInfo = $this->getServerInfo(); if (file_exists($pidFile)) { - $pid = file_get_contents($pidFile); + $pid = (int) file_get_contents($pidFile); } elseif (isset($serverInfo[$address])) { $pid = $serverInfo[$address]['pid']; } @@ -80,31 +94,38 @@ protected function isServerRunningForAddress($address) $this->stopServer($address); } - list($hostname, $port) = explode(':', $address); + [$hostname, $port] = explode(':', $address); return PortUtil::isPortInUse((int) $port, $hostname); } /** - * Get info on currently running servers. - * - * @param bool $running + * Gets info on currently running servers. * - * @return array + * @return array */ - protected function getServerInfo($running = true) + protected function getServerInfo(bool $running = true): array { if (!isset($this->serverInfo)) { $this->serverInfo = []; // @todo move this to State service (in a new major version) - $filename = $this->config()->getWritableUserDir() . '/local-servers.json'; + $filename = $this->config->getWritableUserDir() . '/local-servers.json'; if (file_exists($filename)) { - $this->serverInfo = (array) json_decode(file_get_contents($filename), true); + $this->serverInfo = (array) json_decode((string) file_get_contents($filename), true); } } if ($running) { - return array_filter($this->serverInfo, function ($server) { + return array_filter($this->serverInfo, function (array $server): bool { if ($this->isProcessDead($server['pid'])) { $this->stopServer($server['address']); return false; @@ -117,9 +138,9 @@ protected function getServerInfo($running = true) return $this->serverInfo; } - protected function saveServerInfo() + private function saveServerInfo(): void { - $filename = $this->config()->getWritableUserDir() . '/local-servers.json'; + $filename = $this->config->getWritableUserDir() . '/local-servers.json'; if (!empty($this->serverInfo)) { if (!file_put_contents($filename, json_encode($this->serverInfo))) { throw new \RuntimeException('Failed to write server info to: ' . $filename); @@ -130,26 +151,18 @@ protected function saveServerInfo() } /** - * Stop a running server. - * - * @param string $address - * @param int|null $pid - * - * @return bool - * True on success, false on failure. + * Stops a running server. */ - protected function stopServer($address, $pid = null) + protected function stopServer(string $address, ?int $pid = null): bool { $success = true; if ($pid && function_exists('posix_kill')) { - /** @noinspection PhpComposerExtensionStubsInspection */ $success = posix_kill($pid, SIGTERM); if (!$success) { - /** @noinspection PhpComposerExtensionStubsInspection */ $this->stdErr->writeln(sprintf( 'Failed to kill process %d (POSIX error %s)', $pid, - posix_get_last_error() + posix_get_last_error(), )); } } @@ -164,31 +177,25 @@ protected function stopServer($address, $pid = null) } /** - * @param string $address - * @param int $pid - * @param array $info + * @param array{appId: string, projectRoot: string, logFile: string, docRoot: string} $info */ - protected function writeServerInfo($address, $pid, array $info = []) + protected function writeServerInfo(string $address, int $pid, array $info): void { file_put_contents($this->getPidFile($address), $pid); - list($ip, $port) = explode(':', $address); + [$ip, $port] = explode(':', $address); $this->serverInfo[$address] = $info + [ 'address' => $address, 'pid' => $pid, - 'port' => $port, + 'port' => (int) $port, 'ip' => $ip, ]; $this->saveServerInfo(); } /** - * Automatically determine the best port for a new server. - * - * @param int $default - * - * @return int + * Automatically determines the best port for a new server. */ - protected function getPort($default = 3000) + protected function getPort(int $default = 3000): int { $ports = []; foreach ($this->getServerInfo() as $server) { @@ -205,9 +212,9 @@ protected function getPort($default = 3000) * * @return string The filename */ - protected function getPidFile($address) + protected function getPidFile(string $address): string { - return $this->config()->getWritableUserDir() . '/server-' . preg_replace('/\W+/', '-', $address) . '.pid'; + return $this->config->getWritableUserDir() . '/server-' . preg_replace('/\W+/', '-', $address) . '.pid'; } /** @@ -216,26 +223,25 @@ protected function getPidFile($address) * @param string $address * @param string $docRoot * @param string $projectRoot - * @param array $appConfig - * @param array $env - * - * @throws \Exception + * @param array $appConfig + * @param array $env * * @return Process + *@throws \Exception + * */ - protected function createServerProcess($address, $docRoot, $projectRoot, array $appConfig, array $env = []) + protected function createServerProcess(string $address, string $docRoot, string $projectRoot, array $appConfig, array $env = []): Process { if (isset($appConfig['type'])) { - $type = explode(':', $appConfig['type'], 2); - $version = isset($type[1]) ? $type[1] : false; - /** @var \Platformsh\Cli\Service\Shell $shell */ - $shell = $this->getService('shell'); + $type = explode(':', (string) $appConfig['type'], 2); + $version = $type[1] ?? false; + $shell = $this->shell; $localPhpVersion = $shell->getPhpVersion(); if ($type[0] === 'php' && $version && version_compare($localPhpVersion, $version, '<')) { $this->stdErr->writeln(sprintf( 'Warning: your local PHP version is %s, but the app expects %s', $localPhpVersion, - $version + $version, )); } } @@ -245,7 +251,7 @@ protected function createServerProcess($address, $docRoot, $projectRoot, array $ if (isset($appConfig['web']['commands']['start'])) { // Bail out. We can't support custom 'start' commands for now. throw new \Exception( - "Not supported: the CLI doesn't support starting a server with a custom 'start' command" + "Not supported: the CLI doesn't support starting a server with a custom 'start' command", ); } @@ -260,18 +266,18 @@ protected function createServerProcess($address, $docRoot, $projectRoot, array $ } $arguments = array_merge($arguments, [ - '-t', - $docRoot, - '-S', - $address, - $router, + '-t', + $docRoot, + '-S', + $address, + $router, ]); $process = new Process($arguments); $process->setTimeout(null); $env += $this->createEnv($projectRoot, $docRoot, $address, $appConfig); $process->setEnv($env); - $envPrefix = $this->config()->get('service.env_prefix'); + $envPrefix = $this->config->getStr('service.env_prefix'); if (isset($env[$envPrefix . 'APP_DIR'])) { $process->setWorkingDirectory($env[$envPrefix . 'APP_DIR']); } @@ -282,15 +288,15 @@ protected function createServerProcess($address, $docRoot, $projectRoot, array $ /** * Get custom PHP configuration for the built-in web server. * - * @return array + * @return array */ - protected function getServerPhpConfig() + private function getServerPhpConfig(): array { $phpConfig = []; // Ensure $_ENV is populated. $variables_order = ini_get('variables_order'); - if (strpos($variables_order, 'E') === false) { + if (!str_contains((string) $variables_order, 'E')) { $phpConfig['variables_order'] = 'E' . $variables_order; } @@ -305,7 +311,7 @@ protected function getServerPhpConfig() * @return string * The absolute path to the router file. */ - protected function createRouter($projectRoot) + private function createRouter(string $projectRoot): string { static $created = []; @@ -314,7 +320,7 @@ protected function createRouter($projectRoot) throw new \RuntimeException(sprintf('Router not found: %s', $router_src)); } - $router = $projectRoot . '/' . $this->config()->get('local.local_dir') . '/' . basename($router_src); + $router = $projectRoot . '/' . $this->config->getStr('local.local_dir') . '/' . basename($router_src); if (!isset($created[$router])) { if (!file_put_contents($router, file_get_contents($router_src))) { throw new \RuntimeException(sprintf('Could not create router file: %s', $router)); @@ -325,12 +331,7 @@ protected function createRouter($projectRoot) return $router; } - /** - * @param string $logFile - * - * @return OutputInterface|false - */ - protected function openLog($logFile) + protected function openLog(string $logFile): false|OutputInterface { $logResource = fopen($logFile, 'a'); if ($logResource) { @@ -341,15 +342,11 @@ protected function openLog($logFile) } /** - * @param string $projectRoot - * @param string $address - * - * @return array + * @return array> */ - protected function getRoutesList($projectRoot, $address) + private function getRoutesList(string $projectRoot, string $address): array { - /** @var \Platformsh\Cli\Local\LocalProject $localProject */ - $localProject = $this->getService('local.project'); + $localProject = $this->localProject; $routesConfig = (array) $localProject->readProjectConfigFile($projectRoot, 'routes.yaml'); $routes = []; @@ -357,10 +354,10 @@ protected function getRoutesList($projectRoot, $address) // If the route starts with http://{default}, replace it with the // $address. This can't accommodate subdomains or HTTPS routes, so // those are ignored. - $url = strpos($route, 'http://{default}') === 0 + $url = str_starts_with($route, 'http://{default}') ? 'http://' . $address . substr($route, 16) : $route; - if (strpos($url, '{default}') !== false) { + if (str_contains($url, '{default}')) { continue; } $routes[$url] = $config + ['original_url' => $route]; @@ -370,41 +367,41 @@ protected function getRoutesList($projectRoot, $address) } /** - * Create the virtual environment variables for a local server. + * Creates the virtual environment variables for a local server. * - * @param string $projectRoot - * @param string $docRoot - * @param string $address - * @param array $appConfig + * @param array $appConfig * - * @return array + * @return array */ - protected function createEnv($projectRoot, $docRoot, $address, array $appConfig) + protected function createEnv(string $projectRoot, string $docRoot, string $address, array $appConfig): array { $realDocRoot = realpath($docRoot); - $envPrefix = $this->config()->get('service.env_prefix'); + if (!$realDocRoot) { + throw new \RuntimeException('Failed to resolve directory: ' . $docRoot); + } + $envPrefix = $this->config->getStr('service.env_prefix'); $env = [ '_PLATFORM_VARIABLES_PREFIX' => $envPrefix, $envPrefix . 'ENVIRONMENT' => '_local', - $envPrefix . 'APPLICATION' => base64_encode(json_encode($appConfig)), - $envPrefix . 'APPLICATION_NAME' => isset($appConfig['name']) ? $appConfig['name'] : '', + $envPrefix . 'APPLICATION' => base64_encode((string) json_encode($appConfig)), + $envPrefix . 'APPLICATION_NAME' => $appConfig['name'] ?? '', $envPrefix . 'DOCUMENT_ROOT' => $realDocRoot, - $envPrefix . 'ROUTES' => base64_encode(json_encode($this->getRoutesList($projectRoot, $address))), + $envPrefix . 'ROUTES' => base64_encode((string) json_encode($this->getRoutesList($projectRoot, $address))), ]; - list($env['IP'], $env['PORT']) = explode(':', $address); + [$env['IP'], $env['PORT']] = explode(':', $address); - if (dirname($realDocRoot, 2) === $projectRoot . '/' . $this->config()->get('local.build_dir')) { + if (dirname($realDocRoot, 2) === $projectRoot . '/' . $this->config->getStr('local.build_dir')) { $env[$envPrefix . 'APP_DIR'] = dirname($realDocRoot); } - if ($projectRoot === $this->getProjectRoot()) { + if ($projectRoot === $this->selector->getProjectRoot()) { try { - $project = $this->getCurrentProject(); + $project = $this->selector->getCurrentProject(); if ($project) { $env[$envPrefix . 'PROJECT'] = $project->id; } - } catch (\Exception $e) { + } catch (\Exception) { // Ignore errors } } @@ -412,14 +409,14 @@ protected function createEnv($projectRoot, $docRoot, $address, array $appConfig) return $env; } - protected function showSecurityWarning() + private function showSecurityWarning(): void { static $shown; if ($shown) { return; } $this->stdErr->writeln( - 'Warning: this uses the PHP built-in web server, which is neither secure nor reliable for production use' + 'Warning: this uses the PHP built-in web server, which is neither secure nor reliable for production use', ); $shown = true; } diff --git a/src/Command/Server/ServerListCommand.php b/src/Command/Server/ServerListCommand.php index e3c0463941..df92034526 100644 --- a/src/Command/Server/ServerListCommand.php +++ b/src/Command/Server/ServerListCommand.php @@ -1,24 +1,31 @@ setName('server:list') - ->setAliases(['servers']) - ->setDescription('List running local project web server(s)') ->addOption('all', 'a', InputOption::VALUE_NONE, 'List all servers'); Table::configureInput($this->getDefinition()); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $servers = $this->getServerInfo(); if (!$servers) { @@ -26,28 +33,24 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - $projectRoot = $this->getProjectRoot(); + $projectRoot = $this->selector->getProjectRoot(); $all = $input->getOption('all'); if (!$all && $projectRoot) { - $servers = array_filter($servers, function ($server) use ($projectRoot) { - return $server['projectRoot'] === $projectRoot; - }); + $servers = array_filter($servers, fn($server): bool => $server['projectRoot'] === $projectRoot); if (!$servers) { $this->stdErr->writeln('No servers are running for this project. Specify --all to view all servers.'); return 1; } } - - $table = $this->getService('table'); $headers = ['Address', 'PID', 'App', 'Project root', 'Log']; $rows = []; foreach ($servers as $address => $server) { - $row = [$address, $server['pid'], $server['appId'], $server['projectRoot']]; + $row = [$address, (string) $server['pid'], $server['appId'], $server['projectRoot']]; $logFile = ltrim(str_replace($server['projectRoot'], '', $server['logFile']), '/'); $row[] = $logFile; $rows[] = $row; } - $table->render($rows, $headers); + $this->table->render($rows, $headers); return 0; } diff --git a/src/Command/Server/ServerRunCommand.php b/src/Command/Server/ServerRunCommand.php index 380bcac78f..2ff5ac7945 100644 --- a/src/Command/Server/ServerRunCommand.php +++ b/src/Command/Server/ServerRunCommand.php @@ -1,22 +1,33 @@ setName('server:run') - ->setDescription('Run a local PHP web server') ->addOption('force', 'f', InputOption::VALUE_NONE, 'Force starting server') ->addOption('app', null, InputOption::VALUE_REQUIRED, 'The app name') ->addOption('ip', null, InputOption::VALUE_REQUIRED, 'The IP address', '127.0.0.1') @@ -25,9 +36,9 @@ protected function configure() Url::configureInput($this->getDefinition()); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $projectRoot = $this->getProjectRoot(); + $projectRoot = $this->selector->getProjectRoot(); if (!$projectRoot) { throw new RootNotFoundException(); } @@ -44,24 +55,20 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - /** @var \Platformsh\Cli\Local\ApplicationFinder $finder */ - $finder = $this->getService('app_finder'); + $finder = $this->applicationFinder; $apps = $finder->findApplications($projectRoot); if (!count($apps)) { $this->stdErr->writeln(sprintf('No applications found in directory: %s', $projectRoot)); return 1; } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $appId = $input->getOption('app'); if (!$appId) { $appChoices = []; foreach ($apps as $appCandidate) { $appChoices[$appCandidate->getId()] = $appCandidate->getId(); } - $appId = $questionHelper->choose($appChoices, 'Enter a number to choose an app:'); + $appId = $this->questionHelper->choose($appChoices, 'Enter a number to choose an app:'); } foreach ($apps as $appCandidate) { if ($appCandidate->getId() === $appId) { @@ -79,7 +86,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(sprintf('Document root not found: %s', $docRoot)); $this->stdErr->writeln(sprintf( 'Build the application with: %s build', - $this->config()->get('application.executable') + $this->config->getStr('application.executable'), )); return 1; } @@ -110,7 +117,7 @@ protected function execute(InputInterface $input, OutputInterface $output) 'A server is already running for the app %s at http://%s, PID %s', $appId, $otherServer['address'], - $otherServer['pid'] + $otherServer['pid'], )); return 1; } @@ -120,7 +127,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(sprintf( 'Stopping server for the app %s at http://%s', $appId, - $otherServer['address'] + $otherServer['address'], )); $this->stopServer($address, $otherServer['pid']); sleep(1); @@ -137,7 +144,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(sprintf( 'A server is already running at address: http://%s, PID %s', $address, - $otherPid === true ? 'unknown' : $otherPid + $otherPid === true ? 'unknown' : $otherPid, )); return 1; } @@ -148,21 +155,21 @@ protected function execute(InputInterface $input, OutputInterface $output) } $process = $this->createServerProcess($address, $docRoot, $projectRoot, $appConfig); - $process->start(function ($type, $buffer) use ($log) { + $process->start(function ($type, $buffer) use ($log): void { $log->write($buffer); }); $pid = $process->getPid(); $this->writeServerInfo($address, $pid, [ - 'appId' => $appId, - 'docRoot' => $docRoot, - 'logFile' => $logFile, - 'projectRoot' => $projectRoot, + 'appId' => $appId, + 'docRoot' => $docRoot, + 'logFile' => $logFile, + 'projectRoot' => $projectRoot, ]); $this->stdErr->writeln(sprintf( 'Web server started at http://%s for app %s', $address, - $appId + $appId, )); if ($logFile) { @@ -174,8 +181,7 @@ protected function execute(InputInterface $input, OutputInterface $output) sleep(1); if ($process->isRunning()) { - /** @var Url $urlService */ - $urlService = $this->getService('url'); + $urlService = $this->url; $urlService->openUrl('http://' . $address); } diff --git a/src/Command/Server/ServerStartCommand.php b/src/Command/Server/ServerStartCommand.php index a97d73f6e8..bed874fdab 100644 --- a/src/Command/Server/ServerStartCommand.php +++ b/src/Command/Server/ServerStartCommand.php @@ -1,39 +1,50 @@ setName('server:start') - ->setDescription('Run PHP web server(s) for the local project') ->addOption('force', 'f', InputOption::VALUE_NONE, 'Force starting servers') ->addOption('ip', null, InputOption::VALUE_REQUIRED, 'The IP address', '127.0.0.1') ->addOption('port', null, InputOption::VALUE_REQUIRED, 'The port of the first server') - ->addOption('log', null, InputOption::VALUE_REQUIRED, 'The name of a log file. Defaults to ' . $this->config()->get('local.local_dir') . '/server.log') - ->addOption('tunnel', null, InputOption::VALUE_NONE, 'Incorporate SSH tunnels to remote ' . $this->config()->get('service.name') . ' environments as relationships'); + ->addOption('log', null, InputOption::VALUE_REQUIRED, 'The name of a log file. Defaults to ' . $this->config->getStr('local.local_dir') . '/server.log') + ->addOption('tunnel', null, InputOption::VALUE_NONE, 'Incorporate SSH tunnels to remote ' . $this->config->getStr('service.name') . ' environments as relationships'); Url::configureInput($this->getDefinition()); } - public function isEnabled() + public function isEnabled(): bool { return ProcessManager::supported() && parent::isEnabled(); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $projectRoot = $this->getProjectRoot(); + $projectRoot = $this->selector->getProjectRoot(); if (!$projectRoot) { throw new RootNotFoundException(); } @@ -50,15 +61,14 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - /** @var \Platformsh\Cli\Local\ApplicationFinder $finder */ - $finder = $this->getService('app_finder'); + $finder = $this->applicationFinder; $apps = $finder->findApplications($projectRoot); if (!count($apps)) { $this->stdErr->writeln(sprintf('No applications found in directory: %s', $projectRoot)); return 1; } - $executable = $this->config()->get('application.executable'); + $executable = $this->config->getStr('application.executable'); $items = []; foreach ($apps as $app) { @@ -68,11 +78,11 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(sprintf( 'Document root not found for app %s: %s', $appId, - $docRoot + $docRoot, )); $this->stdErr->writeln(sprintf( 'Build the application with: %s build', - $executable + $executable, )); continue; } @@ -89,25 +99,25 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($input->getOption('tunnel')) { $bufferedOutput = new BufferedOutput(); - $result = $this->runOtherCommand( + $result = $this->subCommandRunner->run( 'tunnel:info', ['--encode' => true] + ($app->isSingle() ? [] : ['--app' => $appId]), - $bufferedOutput + $bufferedOutput, ); if ($result != 0) { $this->stdErr->writeln(sprintf( 'Failed to get SSH tunnel information for the app %s', - $appId + $appId, )); $this->stdErr->writeln(sprintf( 'Run %s tunnel:open to create tunnels.', - $executable + $executable, )); unset($items[$appId]); continue; } $relationships = $bufferedOutput->fetch(); - $items[$appId]['env'][$this->config()->get('service.env_prefix') . 'RELATIONSHIPS'] = $relationships; + $items[$appId]['env'][$this->config->getStr('service.env_prefix') . 'RELATIONSHIPS'] = $relationships; } } @@ -116,7 +126,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } $logFile = $input->getOption('log') - ?: $projectRoot . '/' . $this->config()->get('local.local_dir') . '/server.log'; + ?: $projectRoot . '/' . $this->config->getStr('local.local_dir') . '/server.log'; $log = $this->openLog($logFile); if (!$log) { $this->stdErr->writeln(sprintf('Failed to open log file for writing: %s', $logFile)); @@ -142,7 +152,7 @@ protected function execute(InputInterface $input, OutputInterface $output) 'A server is already running for the app %s at http://%s, PID %s', $appId, $otherServer['address'], - $otherServer['pid'] + $otherServer['pid'], )); continue; } @@ -152,7 +162,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(sprintf( 'Stopping server for the app %s at http://%s', $appId, - $otherServer['address'] + $otherServer['address'], )); $this->stopServer($address, $otherServer['pid']); sleep(1); @@ -167,7 +177,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(sprintf( 'A server is already running at address: http://%s, PID %s', $address, - $otherPid === true ? 'unknown' : $otherPid + $otherPid === true ? 'unknown' : $otherPid, )); $error = true; continue; @@ -193,10 +203,10 @@ protected function execute(InputInterface $input, OutputInterface $output) // Save metadata on the server. $pid = $process->getPid(); $this->writeServerInfo($address, $pid, [ - 'appId' => $appId, - 'docRoot' => $docRoot, - 'logFile' => $logFile, - 'projectRoot' => $projectRoot, + 'appId' => $appId, + 'docRoot' => $docRoot, + 'logFile' => $logFile, + 'projectRoot' => $projectRoot, ]); // Wait a small time to capture any immediate errors. @@ -212,15 +222,14 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(sprintf( 'Web server started at http://%s for app %s', $address, - $appId + $appId, )); } if (count($processes)) { $this->stdErr->writeln(sprintf('Logs are written to: %s', $logFile)); - /** @var Url $urlService */ - $urlService = $this->getService('url'); + $urlService = $this->url; foreach ($processes as $address => $process) { if ($process->isRunning()) { $urlService->openUrl('http://' . $address); diff --git a/src/Command/Server/ServerStopCommand.php b/src/Command/Server/ServerStopCommand.php index 71f9763eb3..b1ef39d11b 100644 --- a/src/Command/Server/ServerStopCommand.php +++ b/src/Command/Server/ServerStopCommand.php @@ -1,22 +1,30 @@ setName('server:stop') - ->setDescription('Stop local project web server(s)') ->addOption('all', 'a', InputOption::VALUE_NONE, 'Stop all servers'); } - public function isEnabled() + public function isEnabled(): bool { if (!extension_loaded('posix')) { return false; @@ -25,9 +33,9 @@ public function isEnabled() return parent::isEnabled(); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $projectRoot = $this->getProjectRoot(); + $projectRoot = $this->selector->getProjectRoot(); $all = $input->getOption('all'); if (!$all && !$projectRoot) { throw new RootNotFoundException('Specify --all to stop all servers, or go to a project directory'); @@ -38,9 +46,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln('No servers are running'); return 1; } elseif (!$all) { - $servers = array_filter($servers, function ($server) use ($projectRoot) { - return $server['projectRoot'] === $projectRoot; - }); + $servers = array_filter($servers, fn($server): bool => $server['projectRoot'] === $projectRoot); if (!$servers) { $this->stdErr->writeln('No servers are running for this project. Specify --all to stop all servers.'); return 1; diff --git a/src/Command/Service/MongoDB/MongoDumpCommand.php b/src/Command/Service/MongoDB/MongoDumpCommand.php index c5e42a9153..23b1dbe599 100644 --- a/src/Command/Service/MongoDB/MongoDumpCommand.php +++ b/src/Command/Service/MongoDB/MongoDumpCommand.php @@ -1,80 +1,90 @@ setName('service:mongo:dump'); - $this->setAliases(['mongodump']); - $this->setDescription('Create a binary archive dump of data from MongoDB'); $this->addOption('collection', 'c', InputOption::VALUE_REQUIRED, 'The collection to dump'); $this->addOption('gzip', 'z', InputOption::VALUE_NONE, 'Compress the dump using gzip'); $this->addOption('stdout', 'o', InputOption::VALUE_NONE, 'Output to STDOUT instead of a file'); Relationships::configureInput($this->getDefinition()); Ssh::configureInput($this->getDefinition()); - $this->addProjectOption() - ->addEnvironmentOption() - ->addAppOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->selector->addAppOption($this->getDefinition()); + $this->addCompleter($this->selector); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $projectRoot = $this->getProjectRoot(); + $projectRoot = $this->selector->getProjectRoot(); $gzip = $input->getOption('gzip'); - $envPrefix = $this->config()->get('service.env_prefix'); - $host = $this->selectHost($input, getenv($envPrefix . 'RELATIONSHIPS') !== false); - + $envPrefix = $this->config->getStr('service.env_prefix'); + $selection = $this->selector->getSelection($input, new SelectorConfig( + allowLocalHost: getenv($envPrefix . 'RELATIONSHIPS') !== false + && getenv($envPrefix . 'APPLICATION_NAME') !== false, + )); + $host = $this->selector->getHostFromSelection($input, $selection); if ($host instanceof RemoteHost) { - $appName = $this->selectApp($input); + $appName = $selection->getAppName(); } else { - $appName = getenv($envPrefix . 'APPLICATION_NAME'); + $appName = (string) getenv($envPrefix . 'APPLICATION_NAME'); } $dumpFile = false; if (!$input->getOption('stdout')) { - $defaultFilename = $this->getDefaultFilename($this->hasSelectedEnvironment() ? $this->getSelectedEnvironment() : null, $appName, $input->getOption('collection'), $gzip); + $defaultFilename = $this->getDefaultFilename($selection->hasEnvironment() ? $selection->getEnvironment() : null, $appName, $input->getOption('collection'), $gzip); $dumpFile = $projectRoot ? $projectRoot . '/' . $defaultFilename : $defaultFilename; } if ($dumpFile) { if (file_exists($dumpFile)) { - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - if (!$questionHelper->confirm("File exists: $dumpFile. Overwrite?")) { + if (!$this->questionHelper->confirm("File exists: $dumpFile. Overwrite?")) { return 1; } } $this->stdErr->writeln(sprintf( 'Creating %s file: %s', $gzip ? 'gzipped BSON archive' : 'BSON archive', - $dumpFile + $dumpFile, )); } - - /** @var \Platformsh\Cli\Service\Relationships $relationshipsService */ - $relationshipsService = $this->getService('relationships'); - $service = $relationshipsService->chooseService($host, $input, $output, ['mongodb']); + $service = $this->relationships->chooseService($host, $input, $output, ['mongodb']); if (!$service) { return 1; } - $command = 'mongodump ' . $relationshipsService->getDbCommandArgs('mongodump', $service); + $command = 'mongodump ' . $this->relationships->getDbCommandArgs('mongodump', $service); if ($input->getOption('collection')) { $command .= ' --collection ' . OsUtil::escapePosixShellArg($input->getOption('collection')); @@ -115,9 +125,8 @@ protected function execute(InputInterface $input, OutputInterface $output) // If a dump file exists, check that it's excluded in the project's // .gitignore configuration. - if ($dumpFile && file_exists($dumpFile) && $projectRoot && strpos($dumpFile, $projectRoot) === 0) { - /** @var \Platformsh\Cli\Service\Git $git */ - $git = $this->getService('git'); + if ($dumpFile && file_exists($dumpFile) && $projectRoot && str_starts_with($dumpFile, $projectRoot)) { + $git = $this->git; if (!$git->checkIgnore($dumpFile, $projectRoot)) { $this->stdErr->writeln('Warning: the dump file is not excluded by Git'); if ($pos = strrpos($dumpFile, '.bson')) { @@ -136,18 +145,18 @@ protected function execute(InputInterface $input, OutputInterface $output) * * @param Environment|null $environment * @param string|null $appName - * @param string $collection + * @param ?string $collection * @param bool $gzip * * @return string */ private function getDefaultFilename( - Environment $environment = null, - $appName = null, - $collection = '', - $gzip = false) - { - $prefix = $this->config()->get('service.env_prefix'); + ?Environment $environment = null, + ?string $appName = null, + ?string $collection = '', + bool $gzip = false, + ): string { + $prefix = $this->config->getStr('service.env_prefix'); $projectId = $environment ? $environment->project : getenv($prefix . 'PROJECT'); $environmentId = $environment ? $environment->id : getenv($prefix . 'BRANCH'); $defaultFilename = $projectId ?: 'db'; diff --git a/src/Command/Service/MongoDB/MongoExportCommand.php b/src/Command/Service/MongoDB/MongoExportCommand.php index c278cf82e8..2183a4dbee 100644 --- a/src/Command/Service/MongoDB/MongoExportCommand.php +++ b/src/Command/Service/MongoDB/MongoExportCommand.php @@ -1,53 +1,61 @@ setName('service:mongo:export'); - $this->setAliases(['mongoexport']); - $this->setDescription('Export data from MongoDB'); $this->addOption('collection', 'c', InputOption::VALUE_REQUIRED, 'The collection to export'); $this->addOption('jsonArray', null, InputOption::VALUE_NONE, 'Export data as a single JSON array'); $this->addOption('type', null, InputOption::VALUE_REQUIRED, 'The export type, e.g. "csv"'); $this->addOption('fields', 'f', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'The fields to export'); Relationships::configureInput($this->getDefinition()); Ssh::configureInput($this->getDefinition()); - $this->addProjectOption() - ->addEnvironmentOption() - ->addAppOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->selector->addAppOption($this->getDefinition()); + $this->addCompleter($this->selector); $this->addExample('Export a CSV from the "users" collection', '-c users --type csv -f name,email'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { if ($input->getOption('type') === 'csv' && !$input->getOption('fields')) { throw new InvalidArgumentException( 'CSV mode requires a field list.' - . "\n" . 'Use --fields (-f) to specify field(s) to export.' + . "\n" . 'Use --fields (-f) to specify field(s) to export.', ); } + $selection = $this->selector->getSelection($input, new SelectorConfig( + allowLocalHost: $this->relationships->hasLocalEnvVar(), + chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive(), + )); + $host = $this->selector->getHostFromSelection($input, $selection); - /** @var \Platformsh\Cli\Service\Relationships $relationshipsService */ - $relationshipsService = $this->getService('relationships'); - $host = $this->selectHost($input, $relationshipsService->hasLocalEnvVar()); - - $service = $relationshipsService->chooseService($host, $input, $output, ['mongodb']); + $service = $this->relationships->chooseService($host, $input, $output, ['mongodb']); if (!$service) { return 1; } @@ -61,12 +69,10 @@ protected function execute(InputInterface $input, OutputInterface $output) if (empty($collections)) { throw new InvalidArgumentException('No collections found. You can specify one with the --collection (-c) option.'); } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $collection = $questionHelper->choose(array_combine($collections, $collections), 'Enter a number to choose a collection:', null, false); + $collection = $this->questionHelper->choose(array_combine($collections, $collections), 'Enter a number to choose a collection:', null, false); } - $command = 'mongoexport ' . $relationshipsService->getDbCommandArgs('mongoexport', $service); + $command = 'mongoexport ' . $this->relationships->getDbCommandArgs('mongoexport', $service); $command .= ' --collection ' . OsUtil::escapePosixShellArg($collection); if ($input->getOption('type')) { @@ -94,20 +100,17 @@ protected function execute(InputInterface $input, OutputInterface $output) /** * Get collections in the MongoDB database. * - * @param array $service + * @param array{username: string, password: string, host: string, port:int, path: string} $service * @param HostInterface $host * - * @return array + * @return string[] */ - private function getCollections(array $service, HostInterface $host) + private function getCollections(array $service, HostInterface $host): array { - /** @var \Platformsh\Cli\Service\Relationships $relationshipsService */ - $relationshipsService = $this->getService('relationships'); - $js = 'printjson(db.getCollectionNames())'; $command = 'mongo ' - . $relationshipsService->getDbCommandArgs('mongo', $service) + . $this->relationships->getDbCommandArgs('mongo', $service) . ' --quiet --eval ' . OsUtil::escapePosixShellArg($js) . ' 2>/dev/null'; @@ -119,34 +122,12 @@ private function getCollections(array $service, HostInterface $host) // Handle log messages that mongo prints to stdout. // https://jira.mongodb.org/browse/SERVER-23810 // Hopefully the end of the output is a JavaScript array. - if (substr($result, -1) === ']' && substr(trim($result), 0, 1) !== '[' && ($openPos = strrpos($result, "\n[")) !== false) { + if (str_ends_with($result, ']') && !str_starts_with(trim($result), '[') && ($openPos = strrpos($result, "\n[")) !== false) { $result = substr($result, $openPos); } $collections = json_decode($result, true) ?: []; - return array_filter($collections, function ($collection) { - return substr($collection, 0, 7) !== 'system.'; - }); - } - - /** - * {@inheritdoc} - */ - public function completeOptionValues($optionName, CompletionContext $context) - { - if ($optionName === 'type') { - return ['csv']; - } - - return []; - } - - /** - * {@inheritdoc} - */ - public function completeArgumentValues($argumentName, CompletionContext $context) - { - return []; + return array_filter($collections, fn(string $collection): bool => !str_starts_with((string) $collection, 'system.')); } } diff --git a/src/Command/Service/MongoDB/MongoRestoreCommand.php b/src/Command/Service/MongoDB/MongoRestoreCommand.php index 8facdd267e..b278cd5b64 100644 --- a/src/Command/Service/MongoDB/MongoRestoreCommand.php +++ b/src/Command/Service/MongoDB/MongoRestoreCommand.php @@ -1,48 +1,58 @@ setName('service:mongo:restore'); - $this->setAliases(['mongorestore']); - $this->setDescription('Restore a binary archive dump of data into MongoDB'); $this->addOption('collection', 'c', InputOption::VALUE_REQUIRED, 'The collection to restore'); Relationships::configureInput($this->getDefinition()); Ssh::configureInput($this->getDefinition()); - $this->addProjectOption() - ->addEnvironmentOption() - ->addAppOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->selector->addAppOption($this->getDefinition()); + $this->addCompleter($this->selector); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $streams = [STDIN]; + $write = $except = null; if (!stream_select($streams, $write, $except, 0)) { throw new InvalidArgumentException('This command requires a mongodump archive to be piped into STDIN'); } + $selection = $this->selector->getSelection($input, new SelectorConfig( + allowLocalHost: $this->relationships->hasLocalEnvVar(), + chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive(), + )); + $host = $this->selector->getHostFromSelection($input, $selection); - /** @var \Platformsh\Cli\Service\Relationships $relationshipsService */ - $relationshipsService = $this->getService('relationships'); - $host = $this->selectHost($input, $relationshipsService->hasLocalEnvVar()); - - $service = $relationshipsService->chooseService($host, $input, $output, ['mongodb']); + $service = $this->relationships->chooseService($host, $input, $output, ['mongodb']); if (!$service) { return 1; } - $command = 'mongorestore ' . $relationshipsService->getDbCommandArgs('mongorestore', $service); + $command = 'mongorestore ' . $this->relationships->getDbCommandArgs('mongorestore', $service); if ($input->getOption('collection')) { $command .= ' --collection ' . OsUtil::escapePosixShellArg($input->getOption('collection')); diff --git a/src/Command/Service/MongoDB/MongoShellCommand.php b/src/Command/Service/MongoDB/MongoShellCommand.php index 29068288d3..e30d7fad13 100644 --- a/src/Command/Service/MongoDB/MongoShellCommand.php +++ b/src/Command/Service/MongoDB/MongoShellCommand.php @@ -1,48 +1,58 @@ setName('service:mongo:shell'); - $this->setAliases(['mongo']); - $this->setDescription('Use the MongoDB shell'); $this->addOption('eval', null, InputOption::VALUE_REQUIRED, 'Pass a JavaScript fragment to the shell'); Relationships::configureInput($this->getDefinition()); Ssh::configureInput($this->getDefinition()); - $this->addProjectOption() - ->addEnvironmentOption() - ->addAppOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->selector->addAppOption($this->getDefinition()); + $this->addCompleter($this->selector); $this->addExample('Display collection names', "--eval 'printjson(db.getCollectionNames())'"); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { if ($this->runningViaMulti) { throw new \RuntimeException('The mongo-shell command cannot run via multi'); } + $selection = $this->selector->getSelection($input, new SelectorConfig( + allowLocalHost: $this->relationships->hasLocalEnvVar(), + chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive(), + )); + $host = $this->selector->getHostFromSelection($input, $selection); - /** @var \Platformsh\Cli\Service\Relationships $relationshipsService */ - $relationshipsService = $this->getService('relationships'); - $host = $this->selectHost($input, $relationshipsService->hasLocalEnvVar()); - - $service = $relationshipsService->chooseService($host, $input, $output, ['mongodb']); + $service = $this->relationships->chooseService($host, $input, $output, ['mongodb']); if (!$service) { return 1; } - $command = 'mongo ' . $relationshipsService->getDbCommandArgs('mongo', $service); + $command = 'mongo ' . $this->relationships->getDbCommandArgs('mongo', $service); if ($input->getOption('eval')) { $command .= ' --eval ' . OsUtil::escapePosixShellArg($input->getOption('eval')); @@ -55,13 +65,13 @@ protected function execute(InputInterface $input, OutputInterface $output) } // Force TTY output when the input is a terminal. - if ($this->isTerminal(STDIN) && $host instanceof RemoteHost) { + if ($this->io->isTerminal(STDIN) && $host instanceof RemoteHost) { $host->setExtraSshOptions(['RequestTTY yes']); } $this->stdErr->writeln( sprintf('Connecting to MongoDB service via relationship %s on %s', $service['_relationship_name'], $host->getLabel()), - OutputInterface::VERBOSITY_VERBOSE + OutputInterface::VERBOSITY_VERBOSE, ); return $host->runCommandDirect($command); diff --git a/src/Command/Service/RedisCliCommand.php b/src/Command/Service/RedisCliCommand.php index fd8124c55f..3fc09dbaa5 100644 --- a/src/Command/Service/RedisCliCommand.php +++ b/src/Command/Service/RedisCliCommand.php @@ -1,29 +1,38 @@ setName('service:redis-cli'); - $this->setAliases(['redis']); - $this->setDescription('Access the Redis CLI'); $this->addArgument('args', InputArgument::OPTIONAL | InputArgument::IS_ARRAY, 'Arguments to add to the Redis command'); Relationships::configureInput($this->getDefinition()); Ssh::configureInput($this->getDefinition()); - $this->addProjectOption() - ->addEnvironmentOption() - ->addAppOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->selector->addAppOption($this->getDefinition()); + $this->addCompleter($this->selector); $this->addExample('Open the redis-cli shell'); $this->addExample('Ping the Redis server', 'ping'); $this->addExample('Show Redis status information', 'info'); @@ -31,17 +40,19 @@ protected function configure() $this->addExample('Scan keys matching a pattern', '-- "--scan --pattern \'*-11*\'"'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { if ($this->runningViaMulti && !$input->getArgument('args')) { throw new \RuntimeException('The redis-cli command cannot run as a shell via multi'); } - /** @var \Platformsh\Cli\Service\Relationships $relationshipsService */ - $relationshipsService = $this->getService('relationships'); - $host = $this->selectHost($input, $relationshipsService->hasLocalEnvVar()); + $selection = $this->selector->getSelection($input, new SelectorConfig( + allowLocalHost: $this->relationships->hasLocalEnvVar(), + chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive(), + )); + $host = $this->selector->getHostFromSelection($input, $selection); - $service = $relationshipsService->chooseService($host, $input, $output, ['redis']); + $service = $this->relationships->chooseService($host, $input, $output, ['redis']); if (!$service) { return 1; } @@ -49,21 +60,21 @@ protected function execute(InputInterface $input, OutputInterface $output) $redisCommand = sprintf( 'redis-cli -h %s -p %d', OsUtil::escapePosixShellArg($service['host']), - $service['port'] + $service['port'], ); if ($args = $input->getArgument('args')) { if (count($args) === 1) { $redisCommand .= ' ' . $args[0]; } else { - $redisCommand .= ' ' . implode(' ', array_map([OsUtil::class, 'escapePosixShellArg'], $args)); + $redisCommand .= ' ' . implode(' ', array_map(OsUtil::escapePosixShellArg(...), $args)); } - } elseif ($this->isTerminal(STDIN) && $host instanceof RemoteHost) { + } elseif ($this->io->isTerminal(STDIN) && $host instanceof RemoteHost) { // Force TTY output when the input is a terminal. $host->setExtraSshOptions(['RequestTTY yes']); } $this->stdErr->writeln( - sprintf('Connecting to Redis service via relationship %s on %s', $service['_relationship_name'], $host->getLabel()) + sprintf('Connecting to Redis service via relationship %s on %s', $service['_relationship_name'], $host->getLabel()), ); return $host->runCommandDirect($redisCommand); diff --git a/src/Command/Service/ServiceListCommand.php b/src/Command/Service/ServiceListCommand.php index a965c7a76c..235b659bee 100644 --- a/src/Command/Service/ServiceListCommand.php +++ b/src/Command/Service/ServiceListCommand.php @@ -1,42 +1,53 @@ 'Disk (MiB)', 'Size']; + /** @var array */ + private array $tableHeader = ['Name', 'Type', 'disk' => 'Disk (MiB)', 'Size']; - /** - * {@inheritdoc} - */ - protected function configure() + public function __construct(private readonly Api $api, private readonly Config $config, private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table) { - $this->setName('service:list') - ->setAliases(['services']) - ->setDescription('List services in the project') + parent::__construct(); + } + + protected function configure(): void + { + $this ->addOption('refresh', null, InputOption::VALUE_NONE, 'Whether to refresh the cache') ->addOption('pipe', null, InputOption::VALUE_NONE, 'Output a list of service names only'); - $this->addProjectOption() - ->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); Table::configureInput($this->getDefinition(), $this->tableHeader); } /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); + $selection = $this->selector->getSelection($input); // Find a list of deployed services. - $deployment = $this->api() - ->getCurrentDeployment($this->getSelectedEnvironment(), $input->getOption('refresh')); + $deployment = $this->api + ->getCurrentDeployment($selection->getEnvironment(), $input->getOption('refresh')); $services = $deployment->services; if (!count($services)) { @@ -54,60 +65,54 @@ protected function execute(InputInterface $input, OutputInterface $output) return 0; } - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - $rows = []; foreach ($services as $name => $service) { $row = [ $name, - $formatter->format($service->type, 'service_type'), + $this->propertyFormatter->format($service->type, 'service_type'), 'disk' => $service->disk !== null ? $service->disk : '', $service->size, ]; $rows[] = $row; } - - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->stdErr->writeln(sprintf( 'Services on the project %s, environment %s:', - $this->api()->getProjectLabel($this->getSelectedProject()), - $this->api()->getEnvironmentLabel($this->getSelectedEnvironment()) + $this->api->getProjectLabel($selection->getProject()), + $this->api->getEnvironmentLabel($selection->getEnvironment()), )); } - $table->render($rows, $this->tableHeader); + $this->table->render($rows, $this->tableHeader); - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->recommendOtherCommands($deployment); } return 0; } - private function recommendOtherCommands(EnvironmentDeployment $deployment) + private function recommendOtherCommands(EnvironmentDeployment $deployment): void { $lines = []; - $executable = $this->config()->get('application.executable'); + $executable = $this->config->getStr('application.executable'); if ($deployment->webapps) { $lines[] = sprintf( 'To list applications, run: %s apps', - $executable + $executable, ); } if ($deployment->workers) { $lines[] = sprintf( 'To list workers, run: %s workers', - $executable + $executable, ); } if ($info = $deployment->getProperty('project_info', false)) { - if (!empty($info['settings']['sizing_api_enabled']) && $this->config()->get('api.sizing') && $this->config()->isCommandEnabled('resources:set')) { + if (!empty($info['settings']['sizing_api_enabled']) && $this->config->getBool('api.sizing') && $this->config->isCommandEnabled('resources:set')) { $lines[] = sprintf( "To configure resources, run: %s resources:set", - $executable + $executable, ); } } diff --git a/src/Command/Session/SessionSwitchCommand.php b/src/Command/Session/SessionSwitchCommand.php index a59db75109..bf5a5078dc 100644 --- a/src/Command/Session/SessionSwitchCommand.php +++ b/src/Command/Session/SessionSwitchCommand.php @@ -1,35 +1,46 @@ setName('session:switch') - ->setDescription('Switch between sessions') + $this ->addArgument('id', InputArgument::OPTIONAL, 'The new session ID'); $this->setHelp( 'Multiple session IDs allow you to be logged into multiple accounts at the same time.' - . "\n\nThe default ID is \"default\"." + . "\n\nThe default ID is \"default\".", ); $this->addExample('Change to the session named "personal"', 'personal'); $this->addExample('Change to the default session', 'default'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $config = $this->config(); - $previousId = $config->getSessionId(); + $previousId = $this->config->getSessionId(); - $envVar = $config->get('application.env_prefix') . 'SESSION_ID'; + $envVar = $this->config->getStr('application.env_prefix') . 'SESSION_ID'; if (getenv($envVar) !== false) { $this->stdErr->writeln(sprintf('The session ID is set via the environment variable %s.', $envVar)); $this->stdErr->writeln('It cannot be changed using this command.'); @@ -42,19 +53,17 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln('The new session ID is required'); return 1; } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $autocomplete = \array_merge(['default' => ''], \array_flip($this->api()->listSessionIds())); + $autocomplete = \array_merge(['default' => ''], \array_flip($this->api->listSessionIds())); unset($autocomplete[$previousId]); $default = count($autocomplete) === 1 ? key($autocomplete) : false; $this->stdErr->writeln('The current session ID is: ' . $previousId . ''); $this->stdErr->writeln(''); - $newId = $questionHelper->askInput('Enter a new session ID', $default ?: null, \array_keys($autocomplete), function ($sessionId) { + $newId = $this->questionHelper->askInput('Enter a new session ID', $default ?: null, \array_keys($autocomplete), function ($sessionId) { if (empty($sessionId)) { throw new \RuntimeException('The session ID cannot be empty'); } try { - $this->config()->validateSessionId($sessionId); + $this->config->validateSessionId($sessionId); } catch (\InvalidArgumentException $e) { throw new \RuntimeException($e->getMessage()); } @@ -70,36 +79,35 @@ protected function execute(InputInterface $input, OutputInterface $output) return 0; } - $config->setSessionId($newId, true); + $this->config->setSessionId($newId, true); // Reset the API service. - $this->api()->getClient(false, true); + $this->api->getClient(false, true); // Set up SSH config. - if ($this->api()->isLoggedIn()) { - /** @var \Platformsh\Cli\Service\SshConfig $sshConfig */ - $sshConfig = $this->getService('ssh_config'); + if ($this->api->isLoggedIn()) { + $sshConfig = $this->sshConfig; $sshConfig->configureSessionSsh(); } - $this->stdErr->writeln(sprintf('Session ID changed from %s to %s', $previousId, $config->getSessionId())); + $this->stdErr->writeln(sprintf('Session ID changed from %s to %s', $previousId, $this->config->getSessionId())); $this->showAccountInfo(); return 0; } - private function showAccountInfo() + private function showAccountInfo(): void { - if ($this->api()->isLoggedIn()) { - $account = $this->api()->getMyAccount(); + if ($this->api->isLoggedIn()) { + $account = $this->api->getMyAccount(); $this->stdErr->writeln(sprintf( "\nUsername: %s\nEmail address: %s", $account['username'], - $account['email'] + $account['email'], )); return; } - $this->stdErr->writeln(sprintf("\nTo log in, run: %s login", $this->config()->get('application.executable'))); + $this->stdErr->writeln(sprintf("\nTo log in, run: %s login", $this->config->getStr('application.executable'))); } } diff --git a/src/Command/SourceOperation/ListCommand.php b/src/Command/SourceOperation/ListCommand.php index 48a9d423e2..8cdce500a3 100644 --- a/src/Command/SourceOperation/ListCommand.php +++ b/src/Command/SourceOperation/ListCommand.php @@ -1,42 +1,54 @@ */ + private array $tableHeader = ['Operation', 'App', 'Command']; - protected function configure() + public function __construct(private readonly Api $api, private readonly Config $config, private readonly Selector $selector, private readonly Table $table) { - $this->setName('source-operation:list') - ->setAliases(['source-ops']) - ->setDescription('List source operations on an environment') + parent::__construct(); + } + + protected function configure(): void + { + $this ->addOption('full', null, InputOption::VALUE_NONE, 'Do not limit the length of command to display. The default limit is ' . self::COMMAND_MAX_LENGTH . ' lines.'); - $this->addProjectOption(); - $this->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); Table::configureInput($this->getDefinition(), $this->tableHeader); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); + $selection = $this->selector->getSelection($input); try { - $sourceOps = $this->getSelectedEnvironment()->getSourceOperations(); - } catch (OperationUnavailableException $e) { + $sourceOps = $selection->getEnvironment()->getSourceOperations(); + } catch (OperationUnavailableException) { throw new ApiFeatureMissingException('This project does not support source operations.'); } @@ -55,29 +67,26 @@ protected function execute(InputInterface $input, OutputInterface $output) $rows[] = $row; } - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->stdErr->writeln(sprintf( 'Source operations on the project %s, environment %s:', - $this->api()->getProjectLabel($this->getSelectedProject()), - $this->api()->getEnvironmentLabel($this->getSelectedEnvironment()) + $this->api->getProjectLabel($selection->getProject()), + $this->api->getEnvironmentLabel($selection->getEnvironment()), )); } - $table->render($rows, $this->tableHeader); + $this->table->render($rows, $this->tableHeader); - if (!$table->formatIsMachineReadable()) { - $this->stdErr->writeln(\sprintf('To run a source operation, use: %s source-operation:run [operation]', $this->config()->get('application.executable'))); + if (!$this->table->formatIsMachineReadable()) { + $this->stdErr->writeln(\sprintf('To run a source operation, use: %s source-operation:run [operation]', $this->config->getStr('application.executable'))); } return 0; } - private function truncateCommand($cmd) + private function truncateCommand(string $cmd): string { - $lines = \preg_split('/\r?\n/', $cmd); + $lines = (array) \preg_split('/\r?\n/', $cmd); if (count($lines) > self::COMMAND_MAX_LENGTH) { return trim(implode("\n", array_slice($lines, 0, self::COMMAND_MAX_LENGTH))) . "\n# ..."; } diff --git a/src/Command/SourceOperation/RunCommand.php b/src/Command/SourceOperation/RunCommand.php index 03ebc070c1..17955c58cb 100644 --- a/src/Command/SourceOperation/RunCommand.php +++ b/src/Command/SourceOperation/RunCommand.php @@ -1,40 +1,54 @@ setName('source-operation:run') - ->setDescription('Run a source operation') + $this ->addArgument('operation', InputArgument::OPTIONAL, 'The operation name') - ->addOption('variable', null, InputOption::VALUE_REQUIRED|InputOption::VALUE_IS_ARRAY, 'A variable to set during the operation, in the format type:name=value'); + ->addOption('variable', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'A variable to set during the operation, in the format type:name=value'); - $this->addProjectOption(); - $this->addEnvironmentOption(); - $this->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); $this->addExample('Run the "update" operation, setting environment variable FOO=bar', 'update --variable env:FOO=bar'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); + $selection = $this->selector->getSelection($input); $variables = $this->parseVariables($input->getOption('variable')); - $this->debug('Parsed variables: ' . json_encode($variables)); + $this->io->debug('Parsed variables: ' . json_encode($variables)); - $environment = $this->getSelectedEnvironment(); + $environment = $selection->getEnvironment(); $sourceOps = $environment->getSourceOperations(); if (!$sourceOps) { $this->stdErr->writeln('No source operations were found on the environment.'); @@ -47,14 +61,12 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln('The operation argument is required in non-interactive mode.'); return 1; } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); $choices = []; foreach ($sourceOps as $sourceOp) { $choices[$sourceOp->operation] = $sourceOp->operation . ' (app: ' . $sourceOp->app . ')'; } ksort($choices, SORT_NATURAL); - $operation = $questionHelper->choose($choices, 'Enter a number to choose an operation to run:', null, false); + $operation = $this->questionHelper->choose($choices, 'Enter a number to choose an operation to run:', null, false); } $operationNames = []; @@ -62,31 +74,30 @@ protected function execute(InputInterface $input, OutputInterface $output) $operationNames[] = $sourceOp->operation; } if (!in_array($operation, $operationNames, true)) { - $this->stdErr->writeln(sprintf('The source operation %s was not found on the environment %s.', $operation, $this->api()->getEnvironmentLabel($environment, 'comment'))); + $this->stdErr->writeln(sprintf('The source operation %s was not found on the environment %s.', $operation, $this->api->getEnvironmentLabel($environment, 'comment'))); $this->stdErr->writeln(''); - $this->stdErr->writeln(sprintf('To list source operations, run: %s source-ops', $this->config()->get('application.executable'))); + $this->stdErr->writeln(sprintf('To list source operations, run: %s source-ops', $this->config->getStr('application.executable'))); return 1; } try { $this->stdErr->writeln(\sprintf('Running source operation %s', $operation)); - $result = $this->getSelectedEnvironment()->runSourceOperation( + $result = $selection->getEnvironment()->runSourceOperation( $operation, - $variables + $variables, ); - } catch (OperationUnavailableException $e) { + } catch (OperationUnavailableException) { throw new ApiFeatureMissingException('This project does not support source operations.'); } $success = true; - if ($this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $monitor */ - $monitor = $this->getService('activity_monitor'); - $success = $monitor->waitMultiple($result->getActivities(), $this->getSelectedProject()); + if ($this->activityMonitor->shouldWait($input)) { + $monitor = $this->activityMonitor; + $success = $monitor->waitMultiple($result->getActivities(), $selection->getProject()); } - if ($success && $this->selectedProjectIsCurrent()) { + if ($success && $this->selector->isProjectCurrent($selection->getProject())) { $this->stdErr->writeln(''); $this->stdErr->writeln('You may wish to run git pull to update your local repository.'); } @@ -95,16 +106,16 @@ protected function execute(InputInterface $input, OutputInterface $output) } /** - * @param array $variables + * @param string[] $variables * - * @return array + * @return array> */ - private function parseVariables(array $variables) + private function parseVariables(array $variables): array { $map = []; $variable = new Variable(); foreach ($variables as $var) { - list($type, $name, $value) = $variable->parse($var); + [$type, $name, $value] = $variable->parse($var); $map[$type][$name] = $value; } diff --git a/src/Command/SshCert/SshCertInfoCommand.php b/src/Command/SshCert/SshCertInfoCommand.php index d2a0c685ef..e4cc10a77f 100644 --- a/src/Command/SshCert/SshCertInfoCommand.php +++ b/src/Command/SshCert/SshCertInfoCommand.php @@ -1,63 +1,61 @@ setName('ssh-cert:info') - ->setDescription('Display information about the current SSH certificate') ->addOption('no-refresh', null, InputOption::VALUE_NONE, 'Do not refresh the certificate if it is invalid') ->addOption('property', 'P', InputOption::VALUE_REQUIRED, 'The certificate property to display'); PropertyFormatter::configureInput($this->getDefinition()); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - // Initialize the API service to ensure event listeners etc. - $this->api(); - - /** @var \Platformsh\Cli\SshCert\Certifier $certifier */ - $certifier = $this->getService('certifier'); - /** @var \Platformsh\Cli\Service\SshConfig $sshConfig */ - $sshConfig = $this->getService('ssh_config'); - - $cert = $certifier->getExistingCertificate(); - if (!$cert || !$certifier->isValid($cert)) { + $cert = $this->certifier->getExistingCertificate(); + if (!$cert || !$this->certifier->isValid($cert)) { if ($input->getOption('no-refresh')) { $this->stdErr->writeln('No valid SSH certificate found.'); $this->stdErr->writeln('To generate a certificate, run this command again without the --no-refresh option.'); return 1; } - if (!$sshConfig->checkRequiredVersion()) { + if (!$this->sshConfig->checkRequiredVersion()) { return 1; } // Generate a new certificate. - $cert = $certifier->generateCertificate($cert); + $cert = $this->certifier->generateCertificate($cert); } - - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); $properties = [ 'filename' => $cert->certificateFilename(), 'key_filename' => $cert->privateKeyFilename(), 'key_id' => $cert->metadata()->getKeyId(), 'key_type' => $cert->metadata()->getKeyType(), - 'valid_after' => $formatter->formatUnixTimestamp($cert->metadata()->getValidAfter()), - 'valid_before' => $formatter->formatUnixTimestamp($cert->metadata()->getValidBefore()), + 'valid_after' => $this->propertyFormatter->formatUnixTimestamp($cert->metadata()->getValidAfter()), + 'valid_before' => $this->propertyFormatter->formatUnixTimestamp($cert->metadata()->getValidBefore()), 'extensions' => $cert->metadata()->getExtensions(), ]; - $formatter->displayData($output, $properties, $input->getOption('property')); + $this->propertyFormatter->displayData($output, $properties, $input->getOption('property')); return 0; } diff --git a/src/Command/SshCert/SshCertLoadCommand.php b/src/Command/SshCert/SshCertLoadCommand.php index 382da202bc..bdcff99c9f 100644 --- a/src/Command/SshCert/SshCertLoadCommand.php +++ b/src/Command/SshCert/SshCertLoadCommand.php @@ -1,26 +1,40 @@ setName('ssh-cert:load') ->addOption('refresh-only', null, InputOption::VALUE_NONE, 'Only refresh the certificate, if necessary (do not write SSH config)') ->addOption('new', null, InputOption::VALUE_NONE, 'Force the certificate to be refreshed') - ->addOption('new-key', null, InputOption::VALUE_NONE, 'Force a new key pair to be generated') - ->setDescription('Generate an SSH certificate'); + ->addOption('new-key', null, InputOption::VALUE_NONE, 'Force a new key pair to be generated'); $help = 'This command checks if a valid SSH certificate is present, and generates a new one if necessary.'; - if ($this->config()->getWithDefault('ssh.auto_load_cert', false)) { - $envPrefix = $this->config()->get('application.env_prefix'); + if ($this->config->getBool('ssh.auto_load_cert')) { + $envPrefix = $this->config->getStr('application.env_prefix'); $help .= "\n\nCertificates allow you to make SSH connections without having previously uploaded a public key. They are more secure than keys and they allow for other features." . "\n\nNormally the certificate is loaded automatically during login, or when making an SSH connection. So this command is seldom needed." . "\n\nIf you want to set up certificates without login and without an SSH-related command, for example if you are writing a script that uses an API token via an environment variable, then you would probably want to run this command explicitly." @@ -29,17 +43,11 @@ protected function configure() $this->setHelp(\wordwrap($help)); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->warnAboutDeprecatedOptions(['new-key'], 'The --new-key option is deprecated. Use --new instead.'); + $this->io->warnAboutDeprecatedOptions(['new-key'], 'The --new-key option is deprecated. Use --new instead.'); - // Initialize the API service to ensure event listeners etc. - $this->api(); - - /** @var \Platformsh\Cli\SshCert\Certifier $certifier */ - $certifier = $this->getService('certifier'); - - $sshCert = $certifier->getExistingCertificate(); + $sshCert = $this->certifier->getExistingCertificate(); $refreshOnly = $input->getOption('refresh-only'); @@ -55,7 +63,7 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($sshCert && !$input->getOption('new') && !$input->getOption('new-key') - && $certifier->isValid($sshCert)) { + && $this->certifier->isValid($sshCert)) { if ($refreshOnly && $this->stdErr->isQuiet()) { return 0; } @@ -64,19 +72,16 @@ protected function execute(InputInterface $input, OutputInterface $output) $refresh = false; } - /** @var \Platformsh\Cli\Service\SshConfig $sshConfig */ - $sshConfig = $this->getService('ssh_config'); - if ($refresh) { - if (!$sshConfig->checkRequiredVersion()) { + if (!$this->sshConfig->checkRequiredVersion()) { return 1; } if ($refreshOnly && $this->stdErr->isQuiet()) { - $certifier->generateCertificate($sshCert, $input->getOption('new-key')); + $this->certifier->generateCertificate($sshCert, $input->getOption('new-key')); return 0; } $this->stdErr->writeln('Generating SSH certificate...'); - $sshCert = $certifier->generateCertificate($sshCert, $input->getOption('new-key')); + $sshCert = $this->certifier->generateCertificate($sshCert, $input->getOption('new-key')); $this->displayCertificate($sshCert); } @@ -84,22 +89,17 @@ protected function execute(InputInterface $input, OutputInterface $output) return 0; } - $sshConfig->configureHostKeys(); - $hasSessionConfig = $sshConfig->configureSessionSsh(); - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $success = !$hasSessionConfig || $sshConfig->addUserSshConfig($questionHelper); + $this->sshConfig->configureHostKeys(); + $hasSessionConfig = $this->sshConfig->configureSessionSsh(); + $success = !$hasSessionConfig || $this->sshConfig->addUserSshConfig($this->questionHelper); return $success ? 0 : 1; } - private function displayCertificate(Certificate $cert) + private function displayCertificate(Certificate $cert): void { $validBefore = $cert->metadata()->getValidBefore(); - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - $expires = $formatter->formatUnixTimestamp($validBefore); + $expires = $this->propertyFormatter->formatUnixTimestamp($validBefore); $expiresWithColor = $validBefore > time() ? '' . $expires . '' : $expires; $mfaWithColor = $cert->hasMfa() ? 'verified' : 'not verified'; $interactivityMode = '' . ($cert->isApp() ? 'app' : 'interactive') . ''; diff --git a/src/Command/SshKey/SshKeyAddCommand.php b/src/Command/SshKey/SshKeyAddCommand.php index a74af3b00c..5172caad50 100644 --- a/src/Command/SshKey/SshKeyAddCommand.php +++ b/src/Command/SshKey/SshKeyAddCommand.php @@ -1,49 +1,55 @@ setName('ssh-key:add') - ->setDescription('Add a new SSH key') ->addArgument('path', InputArgument::OPTIONAL, 'The path to an existing SSH public key') ->addOption('name', null, InputOption::VALUE_REQUIRED, 'A name to identify the key'); $help = 'This command lets you add an SSH key to your account. It can generate a key using OpenSSH.' - . "\n\n" . $this->certificateNotice(); + . "\n\n" . $this->certificateNotice($this->config); $this->setHelp($help); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - /** @var \Platformsh\Cli\Service\Shell $shellHelper */ - $shellHelper = $this->getService('shell'); - /** @var \Platformsh\Cli\Service\SshKey $sshKeyService */ - $sshKeyService = $this->getService('ssh_key'); - - $sshDir = $this->config()->getHomeDirectory() . DIRECTORY_SEPARATOR . '.ssh'; + $sshDir = $this->config->getHomeDirectory() . DIRECTORY_SEPARATOR . '.ssh'; $this->stdErr->writeln(sprintf( "Adding an SSH key to your %s account (%s)\n", - $this->config()->get('service.name'), - $this->api()->getMyAccount()['email'] + $this->config->getStr('service.name'), + $this->api->getMyAccount()['email'], )); - $this->stdErr->writeln($this->certificateNotice(false)); + $this->stdErr->writeln($this->certificateNotice($this->config, false)); $this->stdErr->writeln(''); - if (!$questionHelper->confirm('Are you sure you want to continue adding a key?', false)) { + if (!$this->questionHelper->confirm('Are you sure you want to continue adding a key?', false)) { $this->stdErr->writeln(''); - $this->stdErr->writeln(\sprintf('To load or check your SSH certificate, run: %s ssh-cert:load', $this->config()->get('application.executable'))); + $this->stdErr->writeln(\sprintf('To load or check your SSH certificate, run: %s ssh-cert:load', $this->config->getStr('application.executable'))); return 1; } $this->stdErr->writeln(''); @@ -55,24 +61,24 @@ protected function execute(InputInterface $input, OutputInterface $output) // Look for an existing local key. if (\file_exists($defaultPublicKeyPath) - && $questionHelper->confirm( - 'Use existing local key ' . \basename($defaultPublicKeyPath) . '?' + && $this->questionHelper->confirm( + 'Use existing local key ' . \basename($defaultPublicKeyPath) . '?', )) { $this->stdErr->writeln(''); $publicKeyPath = $defaultPublicKeyPath; - } elseif ($shellHelper->commandExists('ssh-keygen') - && $questionHelper->confirm('Generate a new key?')) { + } elseif ($this->shell->commandExists('ssh-keygen') + && $this->questionHelper->confirm('Generate a new key?')) { // Offer to generate a key. - $newKeyPath = $this->askNewKeyPath($questionHelper); + $newKeyPath = $this->askNewKeyPath(); $this->stdErr->writeln(''); $args = ['ssh-keygen', '-t', 'ed25519', '-f', $newKeyPath, '-N', '']; - $shellHelper->execute($args, null, true); + $this->shell->mustExecute($args); $publicKeyPath = $newKeyPath . '.pub'; $this->stdErr->writeln("Generated a new key: $publicKeyPath\n"); // An SSH agent is required if the key's filename is not an OpenSSH default. - if (!in_array(basename($newKeyPath), $sshKeyService->defaultKeyNames())) { + if (!in_array(basename($newKeyPath), $this->sshKey->defaultKeyNames())) { $this->stdErr->writeln('Add this key to an SSH agent with:'); $this->stdErr->writeln(' eval $(ssh-agent)'); $this->stdErr->writeln(' ssh-add ' . \escapeshellarg($newKeyPath)); @@ -83,9 +89,9 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln('You must specify the path to a public SSH key'); return 1; } - } elseif (\strpos($publicKeyPath, '.pub') === false && \file_exists($publicKeyPath . '.pub')) { + } elseif (!str_contains((string) $publicKeyPath, '.pub') && \file_exists($publicKeyPath . '.pub')) { $publicKeyPath .= '.pub'; - $this->debug('Using public key: ' . $publicKeyPath . '.pub'); + $this->io->debug('Using public key: ' . $publicKeyPath . '.pub'); } if (!\file_exists($publicKeyPath)) { @@ -94,22 +100,21 @@ protected function execute(InputInterface $input, OutputInterface $output) } // Use ssh-keygen to help validate the key. - if ($shellHelper->commandExists('ssh-keygen')) { - $args = ['ssh-keygen', '-l', '-f', $publicKeyPath]; - if (!$shellHelper->execute($args, null, false)) { + if ($this->shell->commandExists('ssh-keygen')) { + if ($this->shell->execute(['ssh-keygen', '-l', '-f', $publicKeyPath]) === false) { $this->stdErr->writeln("The file does not contain a valid public key: $publicKeyPath"); return 1; } } - $fingerprint = $sshKeyService->getPublicKeyFingerprint($publicKeyPath); + $fingerprint = $this->sshKey->getPublicKeyFingerprint($publicKeyPath); // Check whether the public key already exists in the user's account. if ($this->keyExistsByFingerprint($fingerprint)) { $this->stdErr->writeln('This key already exists in your account.'); $this->stdErr->writeln(\sprintf( 'List your SSH keys with: %s ssh-keys', - $this->config()->get('application.executable') + $this->config->getStr('application.executable'), )); return 0; @@ -123,25 +128,22 @@ protected function execute(InputInterface $input, OutputInterface $output) } // Add the new key. - $this->api()->getClient()->addSshKey($publicKey, $input->getOption('name')); + $this->api->getClient()->addSshKey($publicKey, $input->getOption('name')); $this->stdErr->writeln(\sprintf( 'The SSH key %s has been successfully added to your %s account.', - \basename($publicKeyPath), - $this->config()->get('service.name') + \basename((string) $publicKeyPath), + $this->config->getStr('service.name'), )); // Reset and warm the SSH keys cache. try { - $this->api()->getSshKeys(true); - } catch (\Exception $e) { + $this->api->getSshKeys(true); + } catch (\Exception) { // Suppress exceptions; we do not need the result of this call. } - - /** @var \Platformsh\Cli\Service\SshConfig $sshConfig */ - $sshConfig = $this->getService('ssh_config'); - if ($sshConfig->configureSessionSsh()) { - $sshConfig->addUserSshConfig($questionHelper); + if ($this->sshConfig->configureSessionSsh()) { + $this->sshConfig->addUserSshConfig($this->questionHelper); } return 0; @@ -154,9 +156,9 @@ protected function execute(InputInterface $input, OutputInterface $output) * * @return bool */ - protected function keyExistsByFingerprint($fingerprint) + protected function keyExistsByFingerprint(string $fingerprint): bool { - foreach ($this->api()->getClient()->getSshKeys() as $existingKey) { + foreach ($this->api->getClient()->getSshKeys() as $existingKey) { if ($existingKey->fingerprint === $fingerprint) { return true; } @@ -166,24 +168,20 @@ protected function keyExistsByFingerprint($fingerprint) } /** - * Find the default path for a new SSH key. - * - * @param QuestionHelper $questionHelper - * - * @return string + * Finds the default path for a new SSH key. */ - private function askNewKeyPath(QuestionHelper $questionHelper) + private function askNewKeyPath(): string { - $basename = 'id_ed25519-' . $this->config()->get('application.slug') . '-' . $this->api()->getMyAccount()['username']; - $sshDir = $this->config()->getHomeDirectory() . DIRECTORY_SEPARATOR . '.ssh'; + $basename = 'id_ed25519-' . $this->config->getStr('application.slug') . '-' . $this->api->getMyAccount()['username']; + $sshDir = $this->config->getHomeDirectory() . DIRECTORY_SEPARATOR . '.ssh'; for ($i = 2; \file_exists($sshDir . DIRECTORY_SEPARATOR . $basename); $i++) { $basename .= $i; } - return $questionHelper->askInput('Enter a filename for the new key (relative to ~/.ssh)', $basename, [], function ($path) use ($sshDir) { - if (\substr($path, 0, 1) !== '/') { + return $this->questionHelper->askInput('Enter a filename for the new key (relative to ~/.ssh)', $basename, [], function ($path) use ($sshDir) { + if (!str_starts_with($path, '/')) { if (\substr($path, 0, 1) === '~/') { - $path = $this->config()->getHomeDirectory() . '/' . \substr($path, 2); + $path = $this->config->getHomeDirectory() . '/' . \substr($path, 2); } else { $path = $sshDir . DIRECTORY_SEPARATOR . ltrim($path, '\\/'); } diff --git a/src/Command/SshKey/SshKeyCommandBase.php b/src/Command/SshKey/SshKeyCommandBase.php index fc5b85948b..a3e52f7a51 100644 --- a/src/Command/SshKey/SshKeyCommandBase.php +++ b/src/Command/SshKey/SshKeyCommandBase.php @@ -1,26 +1,25 @@ Notice:' . "\n" . 'SSH keys are no longer needed by default, as SSH certificates are supported.' . "\n" . 'Certificates offer more security than keys.'; if ($recommendCommand) { $notice .= "\n\n" . 'To load or check your SSH certificate, run: ' - . $this->config()->get('application.executable') . ' ssh-cert:load'; + . $config->getStr('application.executable') . ' ssh-cert:load'; } return $notice; } diff --git a/src/Command/SshKey/SshKeyDeleteCommand.php b/src/Command/SshKey/SshKeyDeleteCommand.php index abeab38660..a0421cfdb2 100644 --- a/src/Command/SshKey/SshKeyDeleteCommand.php +++ b/src/Command/SshKey/SshKeyDeleteCommand.php @@ -1,58 +1,66 @@ setName('ssh-key:delete') - ->setDescription('Delete an SSH key') ->addArgument( 'id', InputArgument::OPTIONAL, - 'The ID of the SSH key to delete' + 'The ID of the SSH key to delete', ); $this->addExample('Delete the key 123', '123'); $help = 'This command lets you delete SSH keys from your account.' - . "\n\n" . $this->certificateNotice(); + . "\n\n" . $this->certificateNotice($this->config); $this->setHelp($help); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $id = $input->getArgument('id'); if (empty($id) && $input->isInteractive()) { - $keys = $this->api()->getSshKeys(true); + $keys = $this->api->getSshKeys(true); if (empty($keys)) { $this->stdErr->writeln('You do not have any SSH keys in your account.'); return 1; } $options = []; foreach ($keys as $key) { - $options[$key->key_id] = sprintf('%s (%s)', $key->key_id, $key->title ?: $key->fingerprint); + $options[(string) $key->key_id] = sprintf('%s (%s)', $key->key_id, $key->title ?: $key->fingerprint); } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $id = $questionHelper->choose($options, 'Enter a number to choose a key to delete:', null, false); + $id = $this->questionHelper->choose($options, 'Enter a number to choose a key to delete:', null, false); } if (empty($id) || !is_numeric($id)) { $this->stdErr->writeln('You must specify the ID of the SSH key to delete.'); $this->stdErr->writeln(''); $this->stdErr->writeln( - 'List your SSH keys with: ' . $this->config()->get('application.executable') . ' ssh-keys' + 'List your SSH keys with: ' . $this->config->getStr('application.executable') . ' ssh-keys', ); return 1; } - $key = $this->api()->getClient() - ->getSshKey($id); + $key = $this->api->getClient() + ->getSshKey((string) $id); if (!$key) { $this->stdErr->writeln("SSH key not found: $id"); @@ -64,19 +72,16 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(sprintf( 'The SSH key %s has been deleted from your %s account.', $id, - $this->config()->get('service.name') + $this->config->getStr('service.name'), )); // Reset and warm the SSH keys cache. try { - $this->api()->getSshKeys(true); - } catch (\Exception $e) { + $this->api->getSshKeys(true); + } catch (\Exception) { // Suppress exceptions; we do not need the result of this call. } - - /** @var \Platformsh\Cli\Service\SshConfig $sshConfig */ - $sshConfig = $this->getService('ssh_config'); - $sshConfig->configureSessionSsh(); + $this->sshConfig->configureSessionSsh(); return 0; } diff --git a/src/Command/SshKey/SshKeyListCommand.php b/src/Command/SshKey/SshKeyListCommand.php index dbb4a7ba3e..a8e6a91890 100644 --- a/src/Command/SshKey/SshKeyListCommand.php +++ b/src/Command/SshKey/SshKeyListCommand.php @@ -1,50 +1,58 @@ */ + private array $tableHeader = [ 'id' => 'ID', 'title' => 'Title', 'fingerprint' => 'Fingerprint', - 'path' => 'Local path' + 'path' => 'Local path', ]; - private $defaultColumns = ['id', 'title', 'path']; + /** @var string[] */ + private array $defaultColumns = ['id', 'title', 'path']; + public function __construct(private readonly Api $api, private readonly Config $config, private readonly SshKey $sshKey, private readonly Table $table) + { + parent::__construct(); + } - protected function configure() + protected function configure(): void { - $this - ->setName('ssh-key:list') - ->setAliases(['ssh-keys']) - ->setDescription('Get a list of SSH keys in your account'); Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); $help = 'This command lets you list SSH keys in your account.' - . "\n\n" . $this->certificateNotice(); + . "\n\n" . $this->certificateNotice($this->config); $this->setHelp($help); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $keys = $this->api()->getSshKeys(); + $keys = $this->api->getSshKeys(); if (empty($keys)) { $this->stdErr->writeln(sprintf( 'You do not yet have any SSH public keys in your %s account.', - $this->config()->get('service.name') + $this->config->getStr('service.name'), )); } else { - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - /** @var \Platformsh\Cli\Service\SshKey $sshKeyService */ - $sshKeyService = $this->getService('ssh_key'); + $table = $this->table; + $sshKeyService = $this->sshKey; $rows = []; foreach ($keys as $key) { - $row = ['id' => $key->key_id, 'title' => $key->title, 'fingerprint' => $key->fingerprint]; + $row = ['id' => (string) $key->key_id, 'title' => $key->title, 'fingerprint' => $key->fingerprint]; $identity = $sshKeyService->findIdentityMatchingPublicKeys([$key->fingerprint]); $path = $identity ? $identity . '.pub' : ''; if (!$identity && !$table->formatIsMachineReadable()) { @@ -65,12 +73,12 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(''); - $executable = $this->config()->get('application.executable'); + $executable = $this->config->getStr('application.executable'); $this->stdErr->writeln("Add a new SSH key with: $executable ssh-key:add"); $this->stdErr->writeln("Delete an SSH key with: $executable ssh-key:delete [id]"); $this->stdErr->writeln(''); - $this->stdErr->writeln($this->certificateNotice()); + $this->stdErr->writeln($this->certificateNotice($this->config)); return !empty($keys) ? 0 : 1; } diff --git a/src/Command/SubscriptionInfoCommand.php b/src/Command/SubscriptionInfoCommand.php index f8e91e3db9..905ca55821 100644 --- a/src/Command/SubscriptionInfoCommand.php +++ b/src/Command/SubscriptionInfoCommand.php @@ -1,59 +1,63 @@ setName('subscription:info') ->addArgument('property', InputArgument::OPTIONAL, 'The name of the property') ->addArgument('value', InputArgument::OPTIONAL, 'Set a new value for the property') - ->addOption('id', 's', InputOption::VALUE_REQUIRED, 'The subscription ID') - ->setDescription('Read or modify subscription properties'); + ->addOption('id', 's', InputOption::VALUE_REQUIRED, 'The subscription ID'); PropertyFormatter::configureInput($this->getDefinition()); Table::configureInput($this->getDefinition()); - $this->addProjectOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); $this->addExample('View all subscription properties') ->addExample('View the subscription status', 'status') ->addExample('View the storage limit (in MiB)', 'storage'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $id = $input->getOption('id'); $project = null; if (empty($id)) { - $this->validateInput($input); - $project = $this->getSelectedProject(); - $id = $project->getSubscriptionId(); + $selection = $this->selector->getSelection($input); + $project = $selection->getProject(); + $id = (string) $project->getSubscriptionId(); } - $subscription = $this->api()->loadSubscription($id, $project, $input->getArgument('value') !== null); + $subscription = $this->api->loadSubscription($id, $project, $input->getArgument('value') !== null); if (!$subscription) { $this->stdErr->writeln(sprintf('Subscription not found: %s', $id)); return 1; } - $this->formatter = $this->getService('property_formatter'); - $property = $input->getArgument('property'); if (!$property) { @@ -65,16 +69,12 @@ protected function execute(InputInterface $input, OutputInterface $output) return $this->setProperty($property, $value, $subscription); } - switch ($property) { - case 'url': - $value = $subscription->getUri(true); - break; - - default: - $value = $this->api()->getNestedProperty($subscription, $property); - } + $value = match ($property) { + 'url' => $subscription->getUri(true), + default => $this->api->getNestedProperty($subscription, $property), + }; - $output->writeln($this->formatter->format($value, $property)); + $output->writeln($this->propertyFormatter->format($value, $property)); return 0; } @@ -84,29 +84,20 @@ protected function execute(InputInterface $input, OutputInterface $output) * * @return int */ - protected function listProperties(Subscription $subscription) + protected function listProperties(Subscription $subscription): int { $headings = []; $values = []; foreach ($subscription->getProperties() as $key => $value) { $headings[] = new AdaptiveTableCell($key, ['wrap' => false]); - $values[] = $this->formatter->format($value, $key); + $values[] = $this->propertyFormatter->format($value, $key); } - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - $table->renderSimple($values, $headings); + $this->table->renderSimple($values, $headings); return 0; } - /** - * @param string $property - * @param string $value - * @param Subscription $subscription - * - * @return int - */ - protected function setProperty($property, $value, Subscription $subscription) + protected function setProperty(string $property, string $value, Subscription $subscription): int { $type = $this->getType($property); if (!$type) { @@ -120,27 +111,24 @@ protected function setProperty($property, $value, Subscription $subscription) $currentValue = $subscription->getProperty($property); if ($currentValue === $value) { $this->stdErr->writeln( - "Property $property already set as: " . $this->formatter->format($value, $property) + "Property $property already set as: " . $this->propertyFormatter->format($value, $property), ); return 0; } - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); $confirmMessage = sprintf( "Are you sure you want to change property '%s' from %s to %s?", $property, - $this->formatter->format($currentValue, $property), - $this->formatter->format($value, $property) + $this->propertyFormatter->format($currentValue, $property), + $this->propertyFormatter->format($value, $property), ); - if ($this->config()->getWithDefault('warnings.project_users_billing', true)) { + if ($this->config->getBool('warnings.project_users_billing')) { $warning = sprintf( 'This action may %s the cost of your subscription.', - is_numeric($value) && $value > $currentValue ? 'increase' : 'change' + is_numeric($value) && $value > $currentValue ? 'increase' : 'change', ); $confirmMessage = $warning . "\n" . $confirmMessage; - if (!$questionHelper->confirm($confirmMessage)) { + if (!$this->questionHelper->confirm($confirmMessage)) { return 1; } } @@ -149,7 +137,7 @@ protected function setProperty($property, $value, Subscription $subscription) $this->stdErr->writeln(sprintf( 'Property %s set to: %s', $property, - $this->formatter->format($value, $property) + $this->propertyFormatter->format($value, $property), )); return 0; @@ -162,10 +150,10 @@ protected function setProperty($property, $value, Subscription $subscription) * * @return string|false */ - protected function getType($property) + protected function getType(string $property): string|false { $writableProperties = ['plan' => 'string', 'environments' => 'int', 'storage' => 'int']; - return isset($writableProperties[$property]) ? $writableProperties[$property] : false; + return $writableProperties[$property] ?? false; } } diff --git a/src/Command/Team/Project/TeamProjectAddCommand.php b/src/Command/Team/Project/TeamProjectAddCommand.php index 36bccfa541..f248199715 100644 --- a/src/Command/Team/Project/TeamProjectAddCommand.php +++ b/src/Command/Team/Project/TeamProjectAddCommand.php @@ -1,6 +1,12 @@ setName('team:project:add') - ->setDescription('Add project(s) to a team') - ->addArgument('projects', InputArgument::IS_ARRAY, "The project ID(s).\n" . ArrayArgument::SPLIT_HELP) - ->addOption('all', null, InputOption::VALUE_NONE, 'Add all the projects that currently exist in the organization') - ->addOrganizationOptions() - ->addTeamOption(); + $this->addArgument('projects', InputArgument::IS_ARRAY, "The project ID(s).\n" . ArrayArgument::SPLIT_HELP) + ->addOption('all', null, InputOption::VALUE_NONE, 'Add all the projects that currently exist in the organization'); + $this->selector->addOrganizationOptions($this->getDefinition()); + $this->addTeamOption(); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $team = $this->validateTeamInput($input); if (!$team) { return 1; } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $teamProjects = $this->loadTeamProjects($team); - $teamProjectIds = array_map(function (TeamProjectAccess $a) { return $a->project_id; }, $teamProjects); + $teamProjectIds = array_map(fn(TeamProjectAccess $a) => $a->project_id, $teamProjects); $projectIds = ArrayArgument::getArgument($input, 'projects'); @@ -55,7 +63,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } foreach ($orgProjects as $orgProject) { // Pre-cache the label for this project. - $this->api()->getProjectLabel($orgProject); + $this->api->getProjectLabel($orgProject); $projectIds[] = $orgProject->id; } } elseif (!$projectIds && $input->isInteractive()) { @@ -64,13 +72,11 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(sprintf("No projects were found in the team's organization (%s)", $team->organization_id)); return 1; } - $orgProjectsFiltered = array_filter($orgProjects, function (OrgProject $orgProject) use ($teamProjectIds) { - return !in_array($orgProject->id, $teamProjectIds); - }); + $orgProjectsFiltered = array_filter($orgProjects, fn(OrgProject $orgProject): bool => !in_array($orgProject->id, $teamProjectIds)); if (!$orgProjectsFiltered) { $this->stdErr->writeln('The team currently has access to the project(s): '); foreach ($teamProjects as $teamProject) { - $this->stdErr->writeln(sprintf(' • %s', $this->api()->getProjectLabel($teamProject))); + $this->stdErr->writeln(sprintf(' • %s', $this->api->getProjectLabel($teamProject))); } $this->stdErr->writeln('No new projects were found to add.'); return 1; @@ -80,13 +86,13 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($asChoice) { $options = []; foreach ($orgProjectsFiltered as $orgProject) { - $options[$orgProject->id] = $this->api()->getProjectLabel($orgProject, false); + $options[$orgProject->id] = $this->api->getProjectLabel($orgProject, false); } natcasesort($options); $choiceQuestionText = 'Enter a number to select a project to add to the team:'; $default = null; do { - $choice = $questionHelper->choose($options, $choiceQuestionText, $default, false); + $choice = $this->questionHelper->choose($options, $choiceQuestionText, $default, false); unset($options[$choice]); if ($choice === 'finish') { break; @@ -115,8 +121,8 @@ protected function execute(InputInterface $input, OutputInterface $output) $questionText = 'Enter an ID to select a project to add to the team'; $first = true; do { - $choice = $questionHelper->askInput($questionText, null, array_values($autocomplete), function ($value) use ($autocomplete, $first, $teamProjectIds) { - list($id, ) = explode(' - ', $value); + $choice = $this->questionHelper->askInput($questionText, null, array_values($autocomplete), function ($value) use ($autocomplete, $first, $teamProjectIds): ?string { + [$id, ] = explode(' - ', $value); if (empty(trim($id))) { if (!$first) { return null; @@ -151,7 +157,7 @@ protected function execute(InputInterface $input, OutputInterface $output) foreach ($projectIds as $key => $projectId) { if (in_array($projectId, $teamProjectIds)) { - $this->stdErr->writeln(sprintf('The team already has access to the project %s', $this->api()->getProjectLabel($projectId, 'comment'))); + $this->stdErr->writeln(sprintf('The team already has access to the project %s', $this->api->getProjectLabel($projectId, 'comment'))); unset($projectIds[$key]); } } @@ -163,14 +169,14 @@ protected function execute(InputInterface $input, OutputInterface $output) if (count($projectIds) === 1) { $projectId = reset($projectIds); - if (!$questionHelper->confirm(sprintf('Are you sure you want to add the project %s to the team %s?', $this->api()->getProjectLabel($projectId), $this->getTeamLabel($team)))) { + if (!$this->questionHelper->confirm(sprintf('Are you sure you want to add the project %s to the team %s?', $this->api->getProjectLabel($projectId), $this->getTeamLabel($team)))) { return 1; } } else { $this->stdErr->writeln('Selected projects:'); $this->displayProjectsAsList($projectIds, $this->stdErr); $this->stdErr->writeln(''); - if (!$questionHelper->confirm(sprintf('Are you sure you want to add these %d projects to the team %s?', count($projectIds), $this->getTeamLabel($team)))) { + if (!$this->questionHelper->confirm(sprintf('Are you sure you want to add these %d projects to the team %s?', count($projectIds), $this->getTeamLabel($team)))) { return 1; } } @@ -181,7 +187,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } try { - $this->api()->getHttpClient()->post($team->getUri() . '/project-access', ['json' => $payload]); + $this->api->getHttpClient()->post($team->getUri() . '/project-access', ['json' => $payload]); } catch (BadResponseException $e) { throw ApiResponseException::create($e->getRequest(), $e->getResponse(), $e); } @@ -197,11 +203,11 @@ protected function execute(InputInterface $input, OutputInterface $output) * * @param string[] $projectIds */ - private function displayProjectsAsList($projectIds, OutputInterface $output) + private function displayProjectsAsList(array $projectIds, OutputInterface $output): void { $selections = []; foreach ($projectIds as $projectId) { - $selections[] = ' • ' . $this->api()->getProjectLabel($projectId); + $selections[] = ' • ' . $this->api->getProjectLabel($projectId); } natcasesort($selections); $output->writeln($selections); @@ -213,9 +219,9 @@ private function displayProjectsAsList($projectIds, OutputInterface $output) * @param Team $team * @return OrgProject[] */ - private function loadOrgProjects(Team $team) + private function loadOrgProjects(Team $team): array { - $httpClient = $this->api()->getHttpClient(); + $httpClient = $this->api->getHttpClient(); $url = '/organizations/' . rawurlencode($team->organization_id) . '/projects'; /** @var OrgProject[] $projects */ $projects = []; diff --git a/src/Command/Team/Project/TeamProjectDeleteCommand.php b/src/Command/Team/Project/TeamProjectDeleteCommand.php index bb8cbb0ba8..012227fb9c 100644 --- a/src/Command/Team/Project/TeamProjectDeleteCommand.php +++ b/src/Command/Team/Project/TeamProjectDeleteCommand.php @@ -1,38 +1,47 @@ setName('team:project:delete') - ->setDescription('Remove a project from a team') - ->addArgument('project', InputArgument::OPTIONAL, 'The project ID') - ->addOrganizationOptions() - ->addTeamOption(); + $this->addArgument('project', InputArgument::OPTIONAL, 'The project ID'); + $this->selector->addOrganizationOptions($this->getDefinition()); + $this->addTeamOption(); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $team = $this->validateTeamInput($input); if (!$team) { return 1; } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); $teamProjects = $this->loadTeamProjects($team); $projectLabels = []; foreach ($teamProjects as $teamProject) { - $projectLabels[$teamProject->project_id] = $this->api()->getProjectLabel($teamProject, false); + $projectLabels[$teamProject->project_id] = $this->api->getProjectLabel($teamProject, false); } $projectId = $input->getArgument('project'); @@ -43,7 +52,7 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } $questionText = 'Enter a number to choose a project to remove from the team:'; - $projectId = $questionHelper->choose($options, $questionText, null, false); + $projectId = $this->questionHelper->choose($options, $questionText, null, false); } elseif (!$projectId) { $this->stdErr->writeln('A project ID must be specified (in non-interactive mode).'); return 1; @@ -54,12 +63,12 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - if (!$questionHelper->confirm(sprintf('Are you sure you want to remove the project %s from the team %s?', $projectLabels[$projectId], $this->getTeamLabel($team, 'comment')))) { + if (!$this->questionHelper->confirm(sprintf('Are you sure you want to remove the project %s from the team %s?', $projectLabels[$projectId], $this->getTeamLabel($team, 'comment')))) { return 1; } try { - $this->api()->getHttpClient()->delete($team->getUri() . '/project-access/' . rawurlencode($projectId)); + $this->api->getHttpClient()->delete($team->getUri() . '/project-access/' . rawurlencode((string) $projectId)); } catch (BadResponseException $e) { throw ApiResponseException::create($e->getRequest(), $e->getResponse(), $e); } diff --git a/src/Command/Team/Project/TeamProjectListCommand.php b/src/Command/Team/Project/TeamProjectListCommand.php index 00069702a5..a343c29449 100644 --- a/src/Command/Team/Project/TeamProjectListCommand.php +++ b/src/Command/Team/Project/TeamProjectListCommand.php @@ -1,39 +1,48 @@ */ + private array $tableHeader = [ 'id' => 'Project ID', 'title' => 'Project title', 'granted_at' => 'Date added', 'updated_at' => 'Updated at', ]; - private $defaultColumns = ['id', 'title', 'granted_at']; + /** @var string[] */ + private array $defaultColumns = ['id', 'title', 'granted_at']; - /** - * {@inheritdoc} - */ - protected function configure() + public function __construct(private readonly Api $api, private readonly Config $config, private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table) { - $this->setName('team:project:list') - ->setAliases(['team:projects', 'team:pro']) - ->setDescription('List projects in a team') - ->addOption('count', 'c', InputOption::VALUE_REQUIRED, 'The number of items to display per page (max: ' . self::MAX_COUNT . '). Use 0 to disable pagination') - ->addOrganizationOptions() - ->addTeamOption(); + parent::__construct(); + } + + protected function configure(): void + { + $this->addOption('count', 'c', InputOption::VALUE_REQUIRED, 'The number of items to display per page (max: ' . self::MAX_COUNT . '). Use 0 to disable pagination'); + $this->selector->addOrganizationOptions($this->getDefinition()); + $this->addTeamOption(); PropertyFormatter::configureInput($this->getDefinition()); Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); } @@ -41,13 +50,13 @@ protected function configure() /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $options = []; $options['query']['sort'] = 'project_title'; $count = $input->getOption('count'); - $itemsPerPage = (int) $this->config()->getWithDefault('pagination.count', 20); + $itemsPerPage = $this->config->getInt('pagination.count'); if ($count !== null && $count !== '0') { if (!\is_numeric($count) || $count > self::MAX_COUNT) { $this->stdErr->writeln('The --count must be a number between 1 and ' . self::MAX_COUNT . ', or 0 to disable pagination.'); @@ -57,7 +66,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } $options['query']['range'] = $itemsPerPage; - $fetchAllPages = !$this->config()->getWithDefault('pagination.enabled', true); + $fetchAllPages = !$this->config->getBool('pagination.enabled'); if ($count === '0') { $fetchAllPages = true; } @@ -67,7 +76,7 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - $httpClient = $this->api()->getHttpClient(); + $httpClient = $this->api->getHttpClient(); /** @var TeamProjectAccess[] $projects */ $projects = []; $url = $team->getUri() . '/project-access'; @@ -87,40 +96,34 @@ protected function execute(InputInterface $input, OutputInterface $output) if (empty($projects)) { $this->stdErr->writeln(\sprintf('No projects were found in the team %s.', $this->getTeamLabel($team))); $this->stdErr->writeln(''); - $executable = $this->config()->get('application.executable'); + $executable = $this->config->getStr('application.executable'); $this->stdErr->writeln(\sprintf('To add project(s), run: %s team:project:add', $executable)); return 0; } - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - $rows = []; foreach ($projects as $project) { $rows[] = [ 'id' => new AdaptiveTableCell($project->project_id, ['wrap' => false]), 'title' => $project->project_title, - 'granted_at' => $formatter->format($project->granted_at, 'granted_at'), - 'updated_at' => $formatter->format($project->updated_at, 'updated_at'), + 'granted_at' => $this->propertyFormatter->format($project->granted_at, 'granted_at'), + 'updated_at' => $this->propertyFormatter->format($project->updated_at, 'updated_at'), ]; } - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->stdErr->writeln(\sprintf('Projects in the team %s:', $this->getTeamLabel($team))); } - $table->render($rows, $this->tableHeader, $this->defaultColumns); + $this->table->render($rows, $this->tableHeader, $this->defaultColumns); - if (!$table->formatIsMachineReadable()) { - if (isset($collection['next'])) { + if (!$this->table->formatIsMachineReadable()) { + if ($result['collection']->hasNextPage()) { $this->stdErr->writeln('More projects are available'); $this->stdErr->writeln('List all items with: --count 0 (-c0)'); } - $executable = $this->config()->get('application.executable'); + $executable = $this->config->getStr('application.executable'); $this->stdErr->writeln(''); $this->stdErr->writeln(\sprintf('To add project(s) to the team, run: %s team:project:add [ids...]', $executable)); $this->stdErr->writeln(\sprintf('To delete a project from the team, run: %s team:project:delete [id]', $executable)); diff --git a/src/Command/Team/TeamCommandBase.php b/src/Command/Team/TeamCommandBase.php index 1f36af1494..50990cd59d 100644 --- a/src/Command/Team/TeamCommandBase.php +++ b/src/Command/Team/TeamCommandBase.php @@ -1,7 +1,14 @@ api = $api; + $this->config = $config; + $this->questionHelper = $questionHelper; + $this->selector = $selector; + } + + public function isEnabled(): bool { - return $this->config()->get('api.teams') - && $this->config()->get('api.centralized_permissions') - && $this->config()->get('api.organizations') + return $this->config->getBool('api.teams') + && $this->config->getBool('api.centralized_permissions') + && $this->config->getBool('api.organizations') && parent::isEnabled(); } @@ -27,49 +48,12 @@ public function isEnabled() * * @return self */ - protected function addTeamOption() + protected function addTeamOption(): self { $this->addOption('team', 't', InputOption::VALUE_REQUIRED, 'The team ID'); return $this; } - /** - * Selects an organization that has teams support. - * - * @param InputInterface $input - * @return false|Organization - */ - protected function selectOrganization(InputInterface $input) - { - try { - $organization = $this->validateOrganizationInput($input, 'members', 'teams'); - } catch (NoOrganizationsException $e) { - if ($e->getTotalNumOrgs() === 0) { - $this->stdErr->writeln('No organizations found.'); - if ($this->getApplication()->has('organization:create')) { - $this->stdErr->writeln(''); - $this->stdErr->writeln(sprintf('To create an organization, run: %s org:create', $this->config()->get('application.executable'))); - } - return false; - } - $this->stdErr->writeln('No organizations were found in which you can manage teams.'); - if ($this->getApplication()->has('organization:list')) { - $this->stdErr->writeln(''); - $this->stdErr->writeln(sprintf('To list organizations, run: %s organizations', $this->config()->get('application.executable'))); - } - return false; - } - if (!in_array('teams', $organization->capabilities)) { - $this->stdErr->writeln(sprintf('The organization %s does not have teams support.', $this->api()->getOrganizationLabel($organization, 'comment'))); - return false; - } - if (!$organization->hasLink('members')) { - $this->stdErr->writeln(sprintf('You do not have permission to manage teams in the organization %s.', $this->api()->getOrganizationLabel($organization, 'comment'))); - return false; - } - return $organization; - } - /** * Validates the --team option or asks for a team interactively. * @@ -77,7 +61,7 @@ protected function selectOrganization(InputInterface $input) * @param Organization|null $organization * @return Team|false */ - public function validateTeamInput(InputInterface $input, Organization $organization = null) + public function validateTeamInput(InputInterface $input, ?Organization $organization = null): false|Team { if ($organization === null && $input->hasOption('org') && $input->getOption('org') !== null) { $organization = $this->selectOrganization($input); @@ -86,16 +70,16 @@ public function validateTeamInput(InputInterface $input, Organization $organizat } } if ($teamInput = $input->getOption('team')) { - $team = $this->api()->getClient()->getTeam($teamInput); + $team = $this->api->getClient()->getTeam($teamInput); if (!$team) { $this->stdErr->writeln('Team not found: ' . $teamInput . ''); return false; } if ($organization && $team->organization_id !== $organization->id) { - $this->stdErr->writeln(sprintf('The team %s is not part of the selected organization, %s.', $this->getTeamLabel($team, 'error'), $this->api()->getOrganizationLabel($organization, 'error'))); + $this->stdErr->writeln(sprintf('The team %s is not part of the selected organization, %s.', $this->getTeamLabel($team, 'error'), $this->api->getOrganizationLabel($organization, 'error'))); return false; } - if (!$organization && !$this->api()->getOrganizationById($team->organization_id)) { + if (!$organization && !$this->api->getOrganizationById($team->organization_id)) { $this->stdErr->writeln(sprintf('Failed to load team organization: %s.', $team->organization_id)); return false; } @@ -117,7 +101,7 @@ public function validateTeamInput(InputInterface $input, Organization $organizat $teams = $this->loadTeams($organization); if (count($teams) === 0) { - $this->stdErr->writeln(sprintf('No teams were found in the organization %s', $this->api()->getOrganizationLabel($organization, 'error'))); + $this->stdErr->writeln(sprintf('No teams were found in the organization %s', $this->api->getOrganizationLabel($organization, 'error'))); return false; } @@ -131,18 +115,53 @@ public function validateTeamInput(InputInterface $input, Organization $organizat return $this->chooseTeam($teams); } + /** + * Selects an organization that has teams support. + * @noinspection PhpUnused + */ + protected function selectOrganization(InputInterface $input): Organization|false + { + try { + $organization = $this->selector->selectOrganization($input, 'members', 'teams'); + } catch (NoOrganizationsException $e) { + if ($e->getTotalNumOrgs() === 0) { + $this->stdErr->writeln('No organizations found.'); + if ($this->getApplication()->has('organization:create')) { + $this->stdErr->writeln(''); + $this->stdErr->writeln(sprintf('To create an organization, run: %s org:create', $this->config->getStr('application.executable'))); + } + return false; + } + $this->stdErr->writeln('No organizations were found in which you can manage teams.'); + if ($this->getApplication()->has('organization:list')) { + $this->stdErr->writeln(''); + $this->stdErr->writeln(sprintf('To list organizations, run: %s organizations', $this->config->getStr('application.executable'))); + } + return false; + } + if (!in_array('teams', $organization->capabilities)) { + $this->stdErr->writeln(sprintf('The organization %s does not have teams support.', $this->api->getOrganizationLabel($organization, 'comment'))); + return false; + } + if (!$organization->hasLink('members')) { + $this->stdErr->writeln(sprintf('You do not have permission to manage teams in the organization %s.', $this->api->getOrganizationLabel($organization, 'comment'))); + return false; + } + return $organization; + } + /** * Loads teams in an organization. * * @param Organization $organization The organization. * @param bool $fetchAllPages If false, only one page will be fetched. - * @param bool $params Extra query parameters. + * @param array $params Extra query parameters. * * @return Team[] */ - protected function loadTeams(Organization $organization, $fetchAllPages = true, $params = []) + protected function loadTeams(Organization $organization, bool $fetchAllPages = true, array $params = []): array { - $httpClient = $this->api()->getHttpClient(); + $httpClient = $this->api->getHttpClient(); $options = ['query' => array_merge(['filter[organization_id]' => $organization->id, 'sort' => 'label'], $params)]; $url = '/teams'; /** @var Team[] $teams */ @@ -168,7 +187,7 @@ protected function loadTeams(Organization $organization, $fetchAllPages = true, * @param Team[] $teams * @return Team */ - protected function chooseTeam($teams) + protected function chooseTeam(array $teams): Team { $choices = []; $byId = []; @@ -176,9 +195,7 @@ protected function chooseTeam($teams) $choices[$team->id] = $team->label . ' (' . $team->id . ')'; $byId[$team->id] = $team; } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $teamId = $questionHelper->choose($choices, 'Enter a number to choose a team:'); + $teamId = $this->questionHelper->choose($choices, 'Enter a number to choose a team:'); return $byId[$teamId]; } @@ -189,9 +206,9 @@ protected function chooseTeam($teams) * * @return TeamProjectAccess[] */ - protected function loadTeamProjects(Team $team) + protected function loadTeamProjects(Team $team): array { - $httpClient = $this->api()->getHttpClient(); + $httpClient = $this->api->getHttpClient(); /** @var TeamProjectAccess[] $projects */ $projects = []; $options = ['query' => ['sort' => 'project_title']]; @@ -219,7 +236,7 @@ protected function loadTeamProjects(Team $team) * * @return string */ - protected function getTeamLabel(Team $team, $tag = 'info') + protected function getTeamLabel(Team $team, string|false $tag = 'info'): string { $pattern = $tag !== false ? '<%1$s>%2$s (%3$s)' : '%2$s (%3$s)'; diff --git a/src/Command/Team/TeamCreateCommand.php b/src/Command/Team/TeamCreateCommand.php index b255378bde..366f88ad9a 100644 --- a/src/Command/Team/TeamCreateCommand.php +++ b/src/Command/Team/TeamCreateCommand.php @@ -1,6 +1,14 @@ setName('team:create') - ->setDescription('Create a new team') - ->addOption('label', null, InputOption::VALUE_REQUIRED, 'The team label') + $this->addOption('label', null, InputOption::VALUE_REQUIRED, 'The team label') ->addOption('no-check-unique', null, InputOption::VALUE_NONE, 'Do not error if another team exists with the same label in the organization') - ->addOption('role', 'r', InputOption::VALUE_REQUIRED|InputOption::VALUE_IS_ARRAY, "Set the team's project and environment type roles\n" + ->addOption('role', 'r', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, "Set the team's project and environment type roles\n" . ArrayArgument::SPLIT_HELP . "\n" . Wildcard::HELP) - ->addOption('output-id', null, InputOption::VALUE_NONE, "Output the new team's ID to stdout (instead of displaying the team info)") - ->addOrganizationOptions(); + ->addOption('output-id', null, InputOption::VALUE_NONE, "Output the new team's ID to stdout (instead of displaying the team info)"); + $this->selector->addOrganizationOptions($this->getDefinition()); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $update = stripos($input->getFirstArgument(), ':u') !== false; + $update = stripos((string) $input->getFirstArgument(), ':u') !== false; if ($update) { $existingTeam = $this->validateTeamInput($input); if (!$existingTeam) { return 1; } - $organization = $this->api()->getOrganizationById($existingTeam->organization_id); + $organization = $this->api->getOrganizationById($existingTeam->organization_id); if (!$organization) { $this->stdErr->writeln(sprintf('Failed to load team organization: %s.', $existingTeam->organization_id)); return 1; @@ -50,12 +62,9 @@ protected function execute(InputInterface $input, OutputInterface $output) } } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $label = $input->getOption('label'); if ($label === null) { - $label = $questionHelper->askInput("Enter the team's label", $existingTeam ? $existingTeam->label : null, [], function ($value) { + $label = $this->questionHelper->askInput("Enter the team's label", $existingTeam ? $existingTeam->label : null, [], function ($value) { if (empty($value)) { throw new InvalidArgumentException('The label cannot be empty'); } @@ -68,7 +77,7 @@ protected function execute(InputInterface $input, OutputInterface $output) if (!$input->getOption('no-check-unique') && (!$existingTeam || $label !== $existingTeam->label)) { $options = []; $options['query']['filter[organization_id]'] = $organization->id; - $client = $this->api()->getHttpClient(); + $client = $this->api->getHttpClient(); $url = '/teams'; $pageNumber = 1; $progress = new ProgressMessage($this->stdErr); @@ -80,7 +89,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $progress->done(); /** @var Team $team */ foreach ($result['items'] as $team) { - if ((!$existingTeam || $team->id !== $existingTeam->id) && strcasecmp($team->label, $label) === 0) { + if ((!$existingTeam || $team->id !== $existingTeam->id) && strcasecmp($team->label, (string) $label) === 0) { $this->stdErr->writeln(sprintf('Another team %s exists in the organization with the same label: %s', $team->id, $label)); return 1; } @@ -90,23 +99,25 @@ protected function execute(InputInterface $input, OutputInterface $output) } } - $getProjectRole = function (array $perms) { return in_array('admin', $perms) ? 'admin' : 'viewer'; }; - $getEnvTypeRoles = function (array $perms) { + $getProjectRole = fn(array $perms): string => in_array('admin', $perms) ? 'admin' : 'viewer'; + $getEnvTypeRoles = function (array $perms): array { $roles = []; foreach ($perms as $perm) { - if (strpos($perm, ':') !== false) { - list($type, $role) = explode(':', $perm, 2); + if (str_contains($perm, ':')) { + [$type, $role] = explode(':', $perm, 2); $roles[$type] = $role; } } return $roles; }; + /** @var string[] $projectPermissions */ + $projectPermissions = []; if ($roleInput = ArrayArgument::getOption($input, 'role')) { - $specifiedProjectRole = $this->getSpecifiedProjectRole($roleInput); - $specifiedTypeRoles = $this->getSpecifiedTypeRoles($roleInput); - $projectPermissions = [$specifiedProjectRole]; - foreach ($specifiedTypeRoles as $type => $role) { + if ($specifiedProjectRole = $this->getSpecifiedProjectRole($roleInput)) { + $projectPermissions[] = $specifiedProjectRole; + } + foreach ($this->getSpecifiedTypeRoles($roleInput) as $type => $role) { if ($role !== 'none') { $projectPermissions[] = $type . ':' . $role; } @@ -114,15 +125,16 @@ protected function execute(InputInterface $input, OutputInterface $output) } elseif ($input->isInteractive()) { $projectRole = $this->showProjectRoleForm($update ? $getProjectRole($existingTeam->project_permissions) : 'viewer', $input); $this->stdErr->writeln(''); - $environmentTypeRoles = []; + + $projectPermissions[] = $projectRole; + if ($projectRole !== 'admin') { $environmentTypeRoles = $this->showTypeRolesForm($update ? $getEnvTypeRoles($existingTeam->project_permissions) : [], $input); $this->stdErr->writeln(''); - } - $projectPermissions = [$projectRole]; - foreach ($environmentTypeRoles as $type => $role) { - if ($role !== 'none') { - $projectPermissions[] = $type . ':' . $role; + foreach ($environmentTypeRoles as $type => $role) { + if ($role !== 'none') { + $projectPermissions[] = $type . ':' . $role; + } } } } else { @@ -135,20 +147,20 @@ protected function execute(InputInterface $input, OutputInterface $output) } if (!$update) { - if (!$questionHelper->confirm(\sprintf('Are you sure you want to create a new team %s?', $label))) { + if (!$this->questionHelper->confirm(\sprintf('Are you sure you want to create a new team %s?', $label))) { return 1; } $this->stdErr->writeln(''); try { $team = $organization->createTeam($label, $projectPermissions); } catch (BadResponseException $e) { - if ($e->getResponse() && $e->getResponse()->getStatusCode() === 409) { + if ($e->getResponse()->getStatusCode() === 409) { $this->stdErr->writeln(\sprintf('A team already exists with the same label: %s', $label)); return 1; } throw $e; } - $this->stdErr->writeln(sprintf('Created team %s in the organization %s', $this->getTeamLabel($team), $this->api()->getOrganizationLabel($organization))); + $this->stdErr->writeln(sprintf('Created team %s in the organization %s', $this->getTeamLabel($team), $this->api->getOrganizationLabel($organization))); $this->stdErr->writeln(''); } else { $team = $existingTeam; @@ -172,7 +184,7 @@ protected function execute(InputInterface $input, OutputInterface $output) if (isset($currentEnvTypeRoles[$type], $newEnvTypeRoles[$type]) && $currentEnvTypeRoles[$type] === $newEnvTypeRoles[$type]) { continue; } - $changesText[] = sprintf('Role on environment type %s: %s -> %s', $type, isset($currentEnvTypeRoles[$type]) ? $currentEnvTypeRoles[$type] : '[none]', isset($newEnvTypeRoles[$type]) ? $newEnvTypeRoles[$type] : '[none]'); + $changesText[] = sprintf('Role on environment type %s: %s -> %s', $type, $currentEnvTypeRoles[$type] ?? '[none]', $newEnvTypeRoles[$type] ?? '[none]'); } } } @@ -183,7 +195,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln('Summary of changes:'); $this->stdErr->writeln($changesText); $this->stdErr->writeln(''); - if (!$questionHelper->confirm('Are you sure you want to make these changes?')) { + if (!$this->questionHelper->confirm('Are you sure you want to make these changes?')) { return 1; } $this->stdErr->writeln(''); @@ -199,7 +211,7 @@ protected function execute(InputInterface $input, OutputInterface $output) return 0; } - return $this->runOtherCommand('team:get', ['--team' => $team->id], $this->stdErr); + return $this->subCommandRunner->run('team:get', ['--team' => $team->id], $this->stdErr); } /** @@ -210,26 +222,21 @@ protected function execute(InputInterface $input, OutputInterface $output) * * @return string */ - private function showProjectRoleForm($defaultRole, InputInterface $input) + private function showProjectRoleForm(string $defaultRole, InputInterface $input): mixed { - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $validProjectRoles = ['admin', 'viewer']; $this->stdErr->writeln("The team's project role can be " . $this->describeRoles($validProjectRoles) . '.'); $this->stdErr->writeln(''); $question = new Question( sprintf('Project role (default: %s) %s: ', $defaultRole, $this->describeRoleInput($validProjectRoles)), - $defaultRole + $defaultRole, ); - $question->setValidator(function ($answer) { - return $this->validateProjectRole($answer); - }); + $question->setValidator(fn($answer) => $this->validateProjectRole($answer)); $question->setMaxAttempts(5); $question->setAutocompleterValues(ProjectUserAccess::$projectRoles); - return $questionHelper->ask($input, $this->stdErr, $question); + return $this->questionHelper->ask($input, $this->stdErr, $question); } /** @@ -239,11 +246,9 @@ private function showProjectRoleForm($defaultRole, InputInterface $input) * * @return string */ - private function describeRoles(array $roles) + private function describeRoles(array $roles): string { - $withInitials = array_map(function ($role) { - return sprintf('%s (%s)', $role, substr($role, 0, 1)); - }, $roles); + $withInitials = array_map(fn($role): string => sprintf('%s (%s)', $role, substr((string) $role, 0, 1)), $roles); $last = array_pop($withInitials); return implode(' or ', [implode(', ', $withInitials), $last]); @@ -256,20 +261,13 @@ private function describeRoles(array $roles) * * @return string */ - private function describeRoleInput(array $roles) + private function describeRoleInput(array $roles): string { - return '[' . implode('/', array_map(function ($role) { - return substr($role, 0, 1); - }, $roles)) . ']'; + return '[' . implode('/', array_map(fn($role): string => substr((string) $role, 0, 1), $roles)) . ']'; } - /** - * @param string $value - * - * @return string - */ - private function validateProjectRole($value) + private function validateProjectRole(string $value): string { return $this->matchRole($value, ['admin', 'viewer']); } @@ -282,10 +280,10 @@ private function validateProjectRole($value) * * @return string */ - private function matchRole($input, array $roles) + private function matchRole(string $input, array $roles): string { foreach ($roles as $role) { - if (strpos($role, strtolower($input)) === 0) { + if (str_starts_with($role, strtolower($input))) { return $role; } } @@ -296,27 +294,25 @@ private function matchRole($input, array $roles) /** * Show the form for entering environment type roles. * - * @param array $defaultTypeRoles + * @param array $defaultTypeRoles * @param InputInterface $input * - * @return array + * @return array * The environment type roles (keyed by type ID) including the user's * answers. */ - private function showTypeRolesForm(array $defaultTypeRoles, InputInterface $input) + private function showTypeRolesForm(array $defaultTypeRoles, InputInterface $input): array { - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); $desiredTypeRoles = []; $validRoles = array_merge(ProjectUserAccess::$environmentTypeRoles, ['none']); $this->stdErr->writeln("The user's environment type role(s) can be " . $this->describeRoles($validRoles) . '.'); $initials = $this->describeRoleInput($validRoles); $this->stdErr->writeln(''); foreach (['production', 'staging', 'development'] as $id) { - $default = isset($defaultTypeRoles[$id]) ? $defaultTypeRoles[$id] : 'none'; + $default = $defaultTypeRoles[$id] ?? 'none'; $question = new Question( sprintf('Role on type %s (default: %s) %s: ', $id, $default, $initials), - $default + $default, ); $question->setValidator(function ($answer) { if ($answer === 'q' || $answer === 'quit') { @@ -327,7 +323,7 @@ private function showTypeRolesForm(array $defaultTypeRoles, InputInterface $inpu }); $question->setAutocompleterValues(array_merge($validRoles, ['quit'])); $question->setMaxAttempts(5); - $answer = $questionHelper->ask($input, $this->stdErr, $question); + $answer = $this->questionHelper->ask($input, $this->stdErr, $question); if ($answer === 'q' || $answer === 'quit') { break; } else { @@ -338,12 +334,7 @@ private function showTypeRolesForm(array $defaultTypeRoles, InputInterface $inpu return $desiredTypeRoles; } - /** - * @param string $value - * - * @return string - */ - private function validateEnvironmentTypeRole($value) + private function validateEnvironmentTypeRole(string $value): string { return $this->matchRole($value, array_merge(ProjectUserAccess::$environmentTypeRoles, ['none'])); } @@ -351,16 +342,15 @@ private function validateEnvironmentTypeRole($value) /** * Extract the specified project role from the list (given in --role). * - * @param array &$roles + * @param string[] $roles * * @return string|null * The project role, or null if none is specified. */ - private function getSpecifiedProjectRole(array &$roles) + private function getSpecifiedProjectRole(array $roles): ?string { - foreach ($roles as $key => $role) { - if (strpos($role, ':') === false) { - unset($roles[$key]); + foreach ($roles as $role) { + if (!str_contains($role, ':')) { return $this->validateProjectRole($role); } } @@ -378,15 +368,15 @@ private function getSpecifiedProjectRole(array &$roles) * @return array * An array of environment type roles, keyed by environment type ID. */ - private function getSpecifiedTypeRoles(array &$roles) + private function getSpecifiedTypeRoles(array &$roles): array { $typeRoles = []; $typeIds = ['production', 'development', 'staging']; foreach ($roles as $key => $role) { - if (strpos($role, ':') === false) { + if (!str_contains($role, ':')) { continue; } - list($id, $role) = explode(':', $role, 2); + [$id, $role] = explode(':', $role, 2); $role = $this->validateEnvironmentTypeRole($role); // Match type IDs by wildcard. $matched = Wildcard::select($typeIds, [$id]); diff --git a/src/Command/Team/TeamDeleteCommand.php b/src/Command/Team/TeamDeleteCommand.php index a35cd48d08..1e9e14b693 100644 --- a/src/Command/Team/TeamDeleteCommand.php +++ b/src/Command/Team/TeamDeleteCommand.php @@ -1,31 +1,36 @@ setName('team:delete') - ->setDescription('Delete a team') - ->addOrganizationOptions() - ->addTeamOption(); + parent::__construct(); + } + protected function configure(): void + { + $this->selector->addOrganizationOptions($this->getDefinition()); + $this->addTeamOption(); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $team = $this->validateTeamInput($input); if (!$team) { return 1; } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - - if (!$questionHelper->confirm(\sprintf('Are you sure you want to delete the team %s?', $this->getTeamLabel($team, 'comment')), false)) { + if (!$this->questionHelper->confirm(\sprintf('Are you sure you want to delete the team %s?', $this->getTeamLabel($team, 'comment')), false)) { return 1; } diff --git a/src/Command/Team/TeamGetCommand.php b/src/Command/Team/TeamGetCommand.php index fb8bf61067..5394b09730 100644 --- a/src/Command/Team/TeamGetCommand.php +++ b/src/Command/Team/TeamGetCommand.php @@ -1,29 +1,39 @@ setName('team:get') - ->setDescription('View a team') - ->addOrganizationOptions(true) - ->addTeamOption() + parent::__construct(); + } + protected function configure(): void + { + $this->selector->addOrganizationOptions($this->getDefinition(), true); + $this->addCompleter($this->selector); + $this->addTeamOption() ->addOption('property', 'P', InputOption::VALUE_REQUIRED, 'The name of a property to view'); PropertyFormatter::configureInput($this->getDefinition()); Table::configureInput($this->getDefinition()); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $team = $this->validateTeamInput($input); if (!$team) { @@ -31,21 +41,15 @@ protected function execute(InputInterface $input, OutputInterface $output) } $data = array_merge(array_flip(['id', 'label', 'organization_id', 'counts', 'project_permissions']), $team->getProperties()); - /** @var PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - if ($input->getOption('property')) { - $formatter->displayData($output, $data, $input->getOption('property')); + $this->propertyFormatter->displayData($output, $data, $input->getOption('property')); return 0; } - /** @var Table $table */ - $table = $this->getService('table'); - - if (!$table->formatIsMachineReadable()) { - $organization = $this->api()->getOrganizationById($team->organization_id); + if (!$this->table->formatIsMachineReadable()) { + $organization = $this->api->getOrganizationById($team->organization_id); if ($organization) { - $this->stdErr->writeln(\sprintf('Viewing the team %s in the organization %s', $this->getTeamLabel($team), $this->api()->getOrganizationLabel($organization))); + $this->stdErr->writeln(\sprintf('Viewing the team %s in the organization %s', $this->getTeamLabel($team), $this->api->getOrganizationLabel($organization))); } else { $this->stdErr->writeln(\sprintf('Viewing the team %s', $this->getTeamLabel($team))); } @@ -55,13 +59,13 @@ protected function execute(InputInterface $input, OutputInterface $output) $values = []; foreach ($data as $key => $value) { $headings[] = new AdaptiveTableCell($key, ['wrap' => false]); - $values[] = $formatter->format($value, $key); + $values[] = $this->propertyFormatter->format($value, $key); } - $table->renderSimple($values, $headings); + $this->table->renderSimple($values, $headings); - if (!$table->formatIsMachineReadable()) { - $executable = $this->config()->get('application.executable'); + if (!$this->table->formatIsMachineReadable()) { + $executable = $this->config->getStr('application.executable'); $this->stdErr->writeln(''); $this->stdErr->writeln(\sprintf('To add projects to the team, run: %s team:project:add -t %s', $executable, OsUtil::escapeShellArg($team->id))); $this->stdErr->writeln(\sprintf('To add a user to the team, run: %s team:user:add -t %s', $executable, OsUtil::escapeShellArg($team->id))); diff --git a/src/Command/Team/TeamListCommand.php b/src/Command/Team/TeamListCommand.php index 95d91b6401..28d7d50110 100644 --- a/src/Command/Team/TeamListCommand.php +++ b/src/Command/Team/TeamListCommand.php @@ -1,7 +1,15 @@ */ + private array $tableHeader = [ 'id' => 'ID', 'label' => 'Label', 'member_count' => '# Users', @@ -25,21 +36,21 @@ class TeamListCommand extends TeamCommandBase 'updated_at' => 'Updated at', 'granted_at' => 'Granted at', ]; - private $defaultColumns = ['id', 'label', 'member_count', 'project_count', 'project_permissions']; + /** @var string[] */ + private array $defaultColumns = ['id', 'label', 'member_count', 'project_count', 'project_permissions']; + public function __construct(private readonly Api $api, private readonly Config $config, private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table) + { + parent::__construct(); + } - /** - * {@inheritdoc} - */ - protected function configure() + protected function configure(): void { - $this->setName('team:list') - ->setAliases(['teams']) - ->setDescription('List teams') - ->addOption('count', 'c', InputOption::VALUE_REQUIRED, 'The number of items to display per page. Use 0 to disable pagination.') + $this->addOption('count', 'c', InputOption::VALUE_REQUIRED, 'The number of items to display per page. Use 0 to disable pagination.') ->addOption('sort', null, InputOption::VALUE_REQUIRED, 'A team property to sort by', 'label') ->addOption('reverse', null, InputOption::VALUE_NONE, 'Sort in reverse order') - ->addOption('all', 'A', InputOption::VALUE_NONE, 'List all teams in the organization (regardless of a selected project)') - ->addOrganizationOptions(true); + ->addOption('all', 'A', InputOption::VALUE_NONE, 'List all teams in the organization (regardless of a selected project)'); + $this->selector->addOrganizationOptions($this->getDefinition(), true); + $this->addCompleter($this->selector); PropertyFormatter::configureInput($this->getDefinition()); Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); $this->addExample('List teams (in the current project, if any)'); @@ -50,12 +61,16 @@ protected function configure() /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $organization = $this->selectOrganization($input); if (!$organization) { return 1; } + $selection = new Selection(); + if ($input->getOption('project') || $this->selector->getCurrentProject()) { + $selection = $this->selector->getSelection($input); + } $params = []; @@ -72,17 +87,17 @@ protected function execute(InputInterface $input, OutputInterface $output) $params['page[size]'] = $count; } - $executable = $this->config()->get('application.executable'); + $executable = $this->config->getStr('application.executable'); // Fetch teams for a specific project. - $projectSpecific = !$input->getOption('all') && $this->hasSelectedProject(); + $projectSpecific = !$input->getOption('all') && $selection->hasProject(); if ($projectSpecific) { - $teamsOnProject = $this->loadTeamsOnProject($this->getSelectedProject()); + $teamsOnProject = $this->loadTeamsOnProject($selection->getProject()); if (!$teamsOnProject) { - $this->stdErr->writeln(sprintf('No teams found on the project %s.', $this->api()->getProjectLabel($this->getSelectedProject(), 'comment'))); + $this->stdErr->writeln(sprintf('No teams found on the project %s.', $this->api->getProjectLabel($selection->getProject(), 'comment'))); $this->stdErr->writeln(''); $this->stdErr->writeln(\sprintf('To list all teams in the organization, run: %s teams --all', $executable)); - $this->stdErr->writeln(\sprintf('To add this project to a team, run: %s team:project:add %s', $executable, OsUtil::escapeShellArg($this->getSelectedProject()->id))); + $this->stdErr->writeln(\sprintf('To add this project to a team, run: %s team:project:add %s', $executable, OsUtil::escapeShellArg($selection->getProject()->id))); return 1; } $params['filter[id][in]'] = implode(',', array_keys($teamsOnProject)); @@ -91,19 +106,14 @@ protected function execute(InputInterface $input, OutputInterface $output) $teams = $this->loadTeams($organization, $fetchAllPages, $params); if (empty($teams)) { $this->stdErr->writeln('No teams found'); - if ($this->config()->isCommandEnabled('team:create')) { + if ($this->config->isCommandEnabled('team:create')) { $this->stdErr->writeln(''); $this->stdErr->writeln(\sprintf('To create a new team, run: %s team:create', $executable)); } return 1; } - /** @var Table $table */ - $table = $this->getService('table'); - /** @var PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - - $machineReadable = $table->formatIsMachineReadable(); + $machineReadable = $this->table->formatIsMachineReadable(); $rolesUtil = new ProjectRoles(); @@ -112,25 +122,25 @@ protected function execute(InputInterface $input, OutputInterface $output) $row = [ 'id' => $team->id, 'label' => $team->label, - 'member_count' => $formatter->format($team->counts['member_count']), - 'project_count' => $formatter->format($team->counts['project_count']), + 'member_count' => $this->propertyFormatter->format($team->counts['member_count']), + 'project_count' => $this->propertyFormatter->format($team->counts['project_count']), 'project_permissions' => $rolesUtil->formatPermissions($team->project_permissions, $machineReadable), - 'created_at' => $formatter->format($team->created_at, 'created_at'), - 'updated_at' => $formatter->format($team->created_at, 'updated_at'), - 'granted_at' => isset($teamsOnProject[$team->id]) ? $formatter->format($teamsOnProject[$team->id], 'granted_at') : '', + 'created_at' => $this->propertyFormatter->format($team->created_at, 'created_at'), + 'updated_at' => $this->propertyFormatter->format($team->created_at, 'updated_at'), + 'granted_at' => isset($teamsOnProject[$team->id]) ? $this->propertyFormatter->format($teamsOnProject[$team->id], 'granted_at') : '', ]; $rows[] = $row; } if (!$machineReadable) { if ($projectSpecific) { - $this->stdErr->writeln(sprintf('Teams with access to the project %s:', $this->api()->getProjectLabel($this->getSelectedProject()))); + $this->stdErr->writeln(sprintf('Teams with access to the project %s:', $this->api->getProjectLabel($selection->getProject()))); } else { - $this->stdErr->writeln(sprintf('Teams in the organization %s:', $this->api()->getOrganizationLabel($organization))); + $this->stdErr->writeln(sprintf('Teams in the organization %s:', $this->api->getOrganizationLabel($organization))); } } - $table->render($rows, $this->tableHeader, $this->defaultColumns); + $this->table->render($rows, $this->tableHeader, $this->defaultColumns); if (!$machineReadable) { $this->stdErr->writeln(''); @@ -153,9 +163,9 @@ protected function execute(InputInterface $input, OutputInterface $output) * @return array * An array mapping team ID to the granted_at date of the team. */ - private function loadTeamsOnProject(Project $project) + private function loadTeamsOnProject(Project $project): array { - $httpClient = $this->api()->getHttpClient(); + $httpClient = $this->api->getHttpClient(); $url = $project->getUri() . '/team-access'; $info = []; $progress = new ProgressMessage($this->stdErr); @@ -165,15 +175,16 @@ private function loadTeamsOnProject(Project $project) $progress->showIfOutputDecorated(sprintf('Loading project teams (page %d)...', $pageNumber)); } try { - $data = $httpClient->get($url)->json(); + $response = $httpClient->get($url); } catch (BadResponseException $e) { throw ApiResponseException::create($e->getRequest(), $e->getResponse(), $e); } + $data = (array) Utils::jsonDecode((string) $response->getBody(), true); foreach ($data['items'] as $item) { $info[$item['team_id']] = $item['granted_at']; } $progress->done(); - $url = isset($data['_links']['next']['href']) ? $data['_links']['next']['href'] : null; + $url = $data['_links']['next']['href'] ?? null; $pageNumber++; } while ($url); return $info; diff --git a/src/Command/Team/TeamUpdateCommand.php b/src/Command/Team/TeamUpdateCommand.php index 3564516ccf..2cdf4bfbc0 100644 --- a/src/Command/Team/TeamUpdateCommand.php +++ b/src/Command/Team/TeamUpdateCommand.php @@ -1,27 +1,28 @@ setName('team:update') - ->setDescription('Update a team') - ->addOption('label', null, InputOption::VALUE_REQUIRED, 'Set a new team label') + $this->addOption('label', null, InputOption::VALUE_REQUIRED, 'Set a new team label') ->addOption('no-check-unique', null, InputOption::VALUE_NONE, 'Do not error if another team exists with the same label in the organization') - ->addOption('role', 'r', InputOption::VALUE_REQUIRED|InputOption::VALUE_IS_ARRAY, "Set the team's project and environment type roles\n" - . ArrayArgument::SPLIT_HELP . "\n" . Wildcard::HELP) - ->addTeamOption() - ->addOrganizationOptions() - ->addWaitOptions(); + ->addOption('role', 'r', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, "Set the team's project and environment type roles\n" + . ArrayArgument::SPLIT_HELP . "\n" . Wildcard::HELP); + $this->addTeamOption(); + $this->selector->addOrganizationOptions($this->getDefinition()); + $this->activityMonitor->addWaitOptions($this->getDefinition()); } } diff --git a/src/Command/Team/User/TeamUserAddCommand.php b/src/Command/Team/User/TeamUserAddCommand.php index 2e088e6e79..b205879c33 100644 --- a/src/Command/Team/User/TeamUserAddCommand.php +++ b/src/Command/Team/User/TeamUserAddCommand.php @@ -1,41 +1,49 @@ setName('team:user:add') - ->setDescription('Add a user to a team') - ->addArgument('user', InputArgument::OPTIONAL, 'The user email address or ID') - ->addOrganizationOptions() - ->addTeamOption(); + parent::__construct(); + } + protected function configure(): void + { + $this->addArgument('user', InputArgument::OPTIONAL, 'The user email address or ID'); + $this->selector->addOrganizationOptions($this->getDefinition()); + $this->addTeamOption(); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $team = $this->validateTeamInput($input); if (!$team) { return 1; } - $organization = $this->api()->getOrganizationById($team->organization_id); + $organization = $this->api->getOrganizationById($team->organization_id); if (!$organization) { $this->stdErr->writeln(sprintf('Failed to load team organization: %s.', $team->organization_id)); return 1; } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $identifier = $input->getArgument('user'); if (!$identifier) { if (!$input->isInteractive()) { @@ -43,12 +51,12 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } $emails = []; - foreach ($this->api()->listMembers($organization) as $member) { + foreach ($this->api->listMembers($organization) as $member) { if ($info = $member->getUserInfo()) { $emails[] = $info->email; } } - $identifier = $questionHelper->askInput('Enter an email address to add a user', null, $emails, function ($value) { + $identifier = $this->questionHelper->askInput('Enter an email address to add a user', null, $emails, function (string $value): string { if (!filter_var($value, FILTER_VALIDATE_EMAIL)) { throw new InvalidArgumentException('Invalid email address:' . $value); } @@ -57,19 +65,19 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(''); } - if (strpos($identifier, '@') !== false) { - $orgMember = $this->api()->loadMemberByEmail($organization, $identifier); + if (str_contains((string) $identifier, '@')) { + $orgMember = $this->api->loadMemberByEmail($organization, $identifier); if (!$orgMember) { - $this->stdErr->writeln(sprintf('The user with email address %s was not found in the organization %s.', $identifier, $this->api()->getOrganizationLabel($organization, 'comment'))); + $this->stdErr->writeln(sprintf('The user with email address %s was not found in the organization %s.', $identifier, $this->api->getOrganizationLabel($organization, 'comment'))); $this->stdErr->writeln(''); $this->stdErr->writeln('A team may only contain users who are part of the organization.'); if ($this->getApplication()->has('organization:user:add')) { $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf( "To invite the user, run:\n %s org:user:add -o %s %s", - $this->config()->get('application.executable'), + $this->config->getStr('application.executable'), OsUtil::escapeShellArg($organization->id), - OsUtil::escapeShellArg($identifier) + OsUtil::escapeShellArg($identifier), )); } return 1; @@ -77,24 +85,24 @@ protected function execute(InputInterface $input, OutputInterface $output) } else { $orgMember = $organization->getMember($identifier); if (!$orgMember) { - $this->stdErr->writeln(sprintf('The user %s was not found in the organization %s.', $identifier, $this->api()->getOrganizationLabel($organization, 'comment'))); + $this->stdErr->writeln(sprintf('The user %s was not found in the organization %s.', $identifier, $this->api->getOrganizationLabel($organization, 'comment'))); return 1; } } if ($team->getMember($orgMember->user_id)) { - $this->stdErr->writeln(sprintf('The user %s is already in the team %s.', $this->api()->getMemberLabel($orgMember), $this->getTeamLabel($team))); + $this->stdErr->writeln(sprintf('The user %s is already in the team %s.', $this->api->getMemberLabel($orgMember), $this->getTeamLabel($team))); return 0; } - if (!$questionHelper->confirm(sprintf('Are you sure you want to add the user %s to the team %s?', $this->api()->getMemberLabel($orgMember), $this->getTeamLabel($team)))) { + if (!$this->questionHelper->confirm(sprintf('Are you sure you want to add the user %s to the team %s?', $this->api->getMemberLabel($orgMember), $this->getTeamLabel($team)))) { return 1; } $payload = ['user_id' => $orgMember->user_id]; try { - $this->api()->getHttpClient()->post($team->getUri() . '/members', ['json' => $payload]); + $this->api->getHttpClient()->post($team->getUri() . '/members', ['json' => $payload]); } catch (BadResponseException $e) { throw ApiResponseException::create($e->getRequest(), $e->getResponse(), $e); } diff --git a/src/Command/Team/User/TeamUserDeleteCommand.php b/src/Command/Team/User/TeamUserDeleteCommand.php index 9a9539e892..28f08b344a 100644 --- a/src/Command/Team/User/TeamUserDeleteCommand.php +++ b/src/Command/Team/User/TeamUserDeleteCommand.php @@ -1,48 +1,55 @@ setName('team:user:delete') - ->setDescription('Remove a user from a team') - ->addArgument('user', InputArgument::OPTIONAL, 'The user email address or ID') - ->addOrganizationOptions() - ->addTeamOption(); + parent::__construct(); + } + protected function configure(): void + { + $this->addArgument('user', InputArgument::OPTIONAL, 'The user email address or ID'); + $this->selector->addOrganizationOptions($this->getDefinition()); + $this->addTeamOption(); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $team = $this->validateTeamInput($input); if (!$team) { return 1; } - $organization = $this->api()->getOrganizationById($team->organization_id); + $organization = $this->api->getOrganizationById($team->organization_id); if (!$organization) { $this->stdErr->writeln(sprintf('Failed to load team organization: %s.', $team->organization_id)); return 1; } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $identifier = $input->getArgument('user'); if ($identifier) { - if (strpos($identifier, '@') !== false) { - $orgMember = $this->api()->loadMemberByEmail($organization, $identifier); + if (str_contains((string) $identifier, '@')) { + $orgMember = $this->api->loadMemberByEmail($organization, $identifier); if (!$orgMember) { - $this->stdErr->writeln(sprintf('The user with email address %s was not found in the organization %s', $identifier, $this->api()->getOrganizationLabel($organization, 'error'))); + $this->stdErr->writeln(sprintf('The user with email address %s was not found in the organization %s', $identifier, $this->api->getOrganizationLabel($organization, 'error'))); return 1; } $member = $team->getMember($orgMember->user_id); @@ -66,17 +73,17 @@ protected function execute(InputInterface $input, OutputInterface $output) $choices = []; $byId = []; foreach ($members as $member) { - $choices[$member->user_id] = $this->api()->getMemberLabel($member); + $choices[$member->user_id] = $this->api->getMemberLabel($member); $byId[$member->user_id] = $member; } - $id = $questionHelper->choose($choices, 'Enter a number to choose a user to remove:', null, false); + $id = $this->questionHelper->choose($choices, 'Enter a number to choose a user to remove:', null, false); $member = $byId[$id]; } else { $this->stdErr->writeln('A user must be specified (in non-interactive mode).'); return 1; } - if (!$questionHelper->confirm(sprintf('Are you sure you want to remove the user %s from the team %s?', $this->api()->getMemberLabel($member), $this->getTeamLabel($team, 'comment')))) { + if (!$this->questionHelper->confirm(sprintf('Are you sure you want to remove the user %s from the team %s?', $this->api->getMemberLabel($member), $this->getTeamLabel($team, 'comment')))) { return 1; } @@ -87,7 +94,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } $this->stdErr->writeln(''); - $this->stdErr->writeln(sprintf('The user %s was successfully removed from the team %s.', $this->api()->getMemberLabel($member), $this->getTeamLabel($team))); + $this->stdErr->writeln(sprintf('The user %s was successfully removed from the team %s.', $this->api->getMemberLabel($member), $this->getTeamLabel($team))); return 0; } @@ -98,9 +105,9 @@ protected function execute(InputInterface $input, OutputInterface $output) * @param Team $team * @return TeamMember[] */ - private function loadMembers(Team $team) + private function loadMembers(Team $team): array { - $httpClient = $this->api()->getHttpClient(); + $httpClient = $this->api->getHttpClient(); /** @var TeamMember[] $members */ $members = []; $url = $team->getUri() . '/members'; diff --git a/src/Command/Team/User/TeamUserListCommand.php b/src/Command/Team/User/TeamUserListCommand.php index 5a78eb9a9f..834fc30fc9 100644 --- a/src/Command/Team/User/TeamUserListCommand.php +++ b/src/Command/Team/User/TeamUserListCommand.php @@ -1,37 +1,45 @@ */ + private array $tableHeader = [ 'id' => 'User ID', 'email' => 'Email address', 'created_at' => 'Date added', 'updated_at' => 'Updated at', ]; - private $defaultColumns = ['id', 'email', 'created_at']; + /** @var string[] */ + private array $defaultColumns = ['id', 'email', 'created_at']; + public function __construct(private readonly Api $api, private readonly Config $config, private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table) + { + parent::__construct(); + } - /** - * {@inheritdoc} - */ - protected function configure() + protected function configure(): void { - $this->setName('team:user:list') - ->setAliases(['team:users']) - ->setDescription('List users in a team') - ->addOption('count', 'c', InputOption::VALUE_REQUIRED, 'The number of items to display per page. Use 0 to disable pagination') - ->addOrganizationOptions() - ->addTeamOption(); + $this->addOption('count', 'c', InputOption::VALUE_REQUIRED, 'The number of items to display per page. Use 0 to disable pagination'); + $this->selector->addOrganizationOptions($this->getDefinition()); + $this->addTeamOption(); PropertyFormatter::configureInput($this->getDefinition()); Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); } @@ -39,12 +47,12 @@ protected function configure() /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $options = []; $count = $input->getOption('count'); - $itemsPerPage = (int) $this->config()->getWithDefault('pagination.count', 20); + $itemsPerPage = $this->config->getInt('pagination.count'); if ($count !== null && $count !== '0') { if (!\is_numeric($count) || $count > 50) { $this->stdErr->writeln('The --count must be a number between 1 and 50, or 0 to disable pagination.'); @@ -54,7 +62,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } $options['query']['page[size]'] = $itemsPerPage; - $fetchAllPages = !$this->config()->getWithDefault('pagination.enabled', true); + $fetchAllPages = !$this->config->getBool('pagination.enabled'); if ($count === '0') { $fetchAllPages = true; } @@ -64,7 +72,7 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - $httpClient = $this->api()->getHttpClient(); + $httpClient = $this->api->getHttpClient(); /** @var TeamMember[] $members */ $members = []; $url = $team->getUri() . '/members'; @@ -83,41 +91,35 @@ protected function execute(InputInterface $input, OutputInterface $output) if (empty($members)) { $this->stdErr->writeln(\sprintf('No users were found in the team %s.', $this->getTeamLabel($team))); - $executable = $this->config()->get('application.executable'); + $executable = $this->config->getStr('application.executable'); $this->stdErr->writeln(''); $this->stdErr->writeln(\sprintf('To add a user, run: %s team:user:add [email]', $executable)); return 0; } - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - $rows = []; foreach ($members as $member) { $rows[] = [ 'id' => new AdaptiveTableCell($member->user_id, ['wrap' => false]), - 'email' => $formatter->format($member->getUserInfo()->email, 'email'), - 'created_at' => $formatter->format($member->created_at, 'created_at'), - 'updated_at' => $formatter->format($member->updated_at, 'updated_at'), + 'email' => $this->propertyFormatter->format($member->getUserInfo()->email, 'email'), + 'created_at' => $this->propertyFormatter->format($member->created_at, 'created_at'), + 'updated_at' => $this->propertyFormatter->format($member->updated_at, 'updated_at'), ]; } - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->stdErr->writeln(\sprintf('Users in the team %s:', $this->getTeamLabel($team))); } - $table->render($rows, $this->tableHeader, $this->defaultColumns); + $this->table->render($rows, $this->tableHeader, $this->defaultColumns); - if (!$table->formatIsMachineReadable()) { - if (isset($collection['next'])) { + if (!$this->table->formatIsMachineReadable()) { + if ($result['collection']->hasNextPage()) { $this->stdErr->writeln('More users are available'); $this->stdErr->writeln('List all items with: --count 0 (-c0)'); } - $executable = $this->config()->get('application.executable'); + $executable = $this->config->getStr('application.executable'); $this->stdErr->writeln(''); $this->stdErr->writeln(\sprintf('To add a user, run: %s team:user:add [email]', $executable)); $this->stdErr->writeln(\sprintf('To delete a user, run: %s team:user:delete [email]', $executable)); diff --git a/src/Command/Tunnel/TunnelCloseCommand.php b/src/Command/Tunnel/TunnelCloseCommand.php index ea52b94a5e..2baa92e9fe 100644 --- a/src/Command/Tunnel/TunnelCloseCommand.php +++ b/src/Command/Tunnel/TunnelCloseCommand.php @@ -1,26 +1,38 @@ setName('tunnel:close') - ->setDescription('Close SSH tunnels') ->addOption('all', 'a', InputOption::VALUE_NONE, 'Close all tunnels'); - $this->addProjectOption(); - $this->addEnvironmentOption(); - $this->addAppOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->selector->addAppOption($this->getDefinition()); + $this->addCompleter($this->selector); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $tunnels = $this->getTunnelInfo(); + $tunnels = $this->tunnelManager->getTunnels(); $allTunnelsCount = count($tunnels); if (!$allTunnelsCount) { $this->stdErr->writeln('No tunnels found.'); @@ -30,43 +42,27 @@ protected function execute(InputInterface $input, OutputInterface $output) // Filter tunnels according to the current project and environment, if // available. if (!$input->getOption('all')) { - $tunnels = $this->filterTunnels($tunnels, $input); + $tunnels = $this->tunnelManager->filterBySelection($tunnels, $this->selector->getSelection($input)); if (!count($tunnels)) { $this->stdErr->writeln('No tunnels found. Use --all to close all tunnels.'); return 1; } } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $error = false; foreach ($tunnels as $tunnel) { - $relationshipString = $this->formatTunnelRelationship($tunnel); - $appString = $tunnel['projectId'] . '-' . $tunnel['environmentId']; - if ($tunnel['appName']) { - $appString .= '--' . $tunnel['appName']; + $relationshipString = $this->tunnelManager->formatRelationship($tunnel); + $appString = $tunnel->metadata['projectId'] . '-' . $tunnel->metadata['environmentId']; + if ($tunnel->metadata['appName']) { + $appString .= '--' . $tunnel->metadata['appName']; } $questionText = sprintf( 'Close tunnel to relationship %s on %s?', $relationshipString, - $appString + $appString, ); - if ($questionHelper->confirm($questionText)) { - if ($this->closeTunnel($tunnel)) { - $this->stdErr->writeln(sprintf( - 'Closed tunnel to %s on %s', - $relationshipString, - $appString - )); - } else { - $error = true; - $this->stdErr->writeln(sprintf( - 'Failed to close tunnel to %s on %s', - $relationshipString, - $appString - )); - } + if ($this->questionHelper->confirm($questionText)) { + $this->tunnelManager->close($tunnel); } } @@ -74,6 +70,6 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln('Use --all to close all tunnels.'); } - return $error ? 1 : 0; + return 0; } } diff --git a/src/Command/Tunnel/TunnelCommandBase.php b/src/Command/Tunnel/TunnelCommandBase.php index 353616fdb4..5bd2b14c50 100644 --- a/src/Command/Tunnel/TunnelCommandBase.php +++ b/src/Command/Tunnel/TunnelCommandBase.php @@ -1,290 +1,12 @@ getTunnelInfo() as $info) { - if ($this->tunnelsAreEqual($tunnel, $info)) { - /** @noinspection PhpComposerExtensionStubsInspection */ - if (isset($info['pid']) && function_exists('posix_kill') && !posix_kill($info['pid'], 0)) { - $this->debug(sprintf( - 'The tunnel at port %d is no longer open, removing from list', - $info['localPort'] - )); - $this->closeTunnel($info); - continue; - } - - return $info; - } - } - - return false; - } - - /** - * Get info on currently open tunnels. - * - * @param bool $open - * - * @return array - */ - protected function getTunnelInfo($open = true) - { - if (!isset($this->tunnelInfo)) { - $this->tunnelInfo = []; - // @todo move this to State service (in a new major version) - $filename = $this->config()->getWritableUserDir() . '/tunnel-info.json'; - if (file_exists($filename)) { - $this->debug(sprintf('Loading tunnel info from %s', $filename)); - $this->tunnelInfo = (array) json_decode(file_get_contents($filename), true); - } - } - - if ($open) { - $needsSave = false; - foreach ($this->tunnelInfo as $key => $tunnel) { - /** @noinspection PhpComposerExtensionStubsInspection */ - if (isset($tunnel['pid']) && function_exists('posix_kill') && !posix_kill($tunnel['pid'], 0)) { - $this->debug(sprintf( - 'The tunnel at port %d is no longer open, removing from list', - $tunnel['localPort'] - )); - unset($this->tunnelInfo[$key]); - $needsSave = true; - } - } - if ($needsSave) { - $this->saveTunnelInfo(); - } - } - - return $this->tunnelInfo; - } - - protected function saveTunnelInfo() - { - $filename = $this->config()->getWritableUserDir() . '/tunnel-info.json'; - if (!empty($this->tunnelInfo)) { - $this->debug('Saving tunnel info to: ' . $filename); - if (!file_put_contents($filename, json_encode($this->tunnelInfo))) { - throw new \RuntimeException('Failed to write tunnel info to: ' . $filename); - } - } else { - unlink($filename); - } - } - - /** - * Close an open tunnel. - * - * @param array $tunnel - * - * @return bool - * True on success, false on failure. - */ - protected function closeTunnel(array $tunnel) - { - $success = true; - if (isset($tunnel['pid']) && function_exists('posix_kill')) { - /** @noinspection PhpComposerExtensionStubsInspection */ - $success = posix_kill($tunnel['pid'], SIGTERM); - if (!$success) { - /** @noinspection PhpComposerExtensionStubsInspection */ - $this->stdErr->writeln(sprintf( - 'Failed to kill process %d (POSIX error %s)', - $tunnel['pid'], - posix_get_last_error() - )); - } - } - $pidFile = $this->getPidFile($tunnel); - if (file_exists($pidFile)) { - $success = unlink($pidFile) && $success; - } - $this->tunnelInfo = array_filter($this->tunnelInfo, function ($info) use ($tunnel) { - return !$this->tunnelsAreEqual($info, $tunnel); - }); - $this->saveTunnelInfo(); - - return $success; - } - - /** - * Automatically determine the best port for a new tunnel. - * - * @param int $default - * - * @return int - */ - protected function getPort($default = 30000) - { - $ports = []; - foreach ($this->getTunnelInfo() as $tunnel) { - $ports[] = $tunnel['localPort']; - } - - return PortUtil::getPort($ports ? max($ports) + 1 : $default); - } - - /** - * @param string $logFile - * - * @return OutputInterface|false - */ - protected function openLog($logFile) - { - $logResource = fopen($logFile, 'a'); - if ($logResource) { - return new StreamOutput($logResource, OutputInterface::VERBOSITY_VERBOSE); - } - - return false; - } - - /** - * @param array $tunnel - * - * @return string - */ - protected function getTunnelKey(array $tunnel) - { - return implode('--', [ - $tunnel['projectId'], - $tunnel['environmentId'], - $tunnel['appName'], - $tunnel['relationship'], - $tunnel['serviceKey'], - ]); - } - - /** - * @param array $tunnel - * @param array $service - * - * @return string - */ - protected function getTunnelUrl(array $tunnel, array $service) - { - /** @var \Platformsh\Cli\Service\Relationships $relationshipsService */ - $relationshipsService = $this->getService('relationships'); - $localService = array_merge($service, array_intersect_key([ - 'host' => self::LOCAL_IP, - 'port' => $tunnel['localPort'], - ], $service)); - - return $relationshipsService->buildUrl($localService); - } - - /** - * @param array $tunnel1 - * @param array $tunnel2 - * - * @return bool - */ - protected function tunnelsAreEqual(array $tunnel1, array $tunnel2) - { - return $this->getTunnelKey($tunnel1) === $this->getTunnelKey($tunnel2); - } - - /** - * @param array $tunnel - * - * @return string - */ - protected function getPidFile(array $tunnel) - { - $key = $this->getTunnelKey($tunnel); - $dir = $this->config()->getWritableUserDir() . '/.tunnels'; - if (!is_dir($dir) && !mkdir($dir, 0700, true)) { - throw new \RuntimeException('Failed to create directory: ' . $dir); - } - - return $dir . '/' . preg_replace('/[^0-9a-z\.]+/', '-', $key) . '.pid'; - } - - /** - * @param string $url - * @param string $remoteHost - * @param int $remotePort - * @param int $localPort - * @param array $extraArgs - * - * @return \Symfony\Component\Process\Process - */ - protected function createTunnelProcess($url, $remoteHost, $remotePort, $localPort, array $extraArgs = []) - { - $args = ['ssh', '-n', '-N', '-L', implode(':', [$localPort, $remoteHost, $remotePort]), $url]; - $args = array_merge($args, $extraArgs); - $process = new Process($args); - $process->setTimeout(null); - - return $process; - } - - /** - * Filter a list of tunnels by the currently selected project/environment. - * - * @param array $tunnels - * @param InputInterface $input - * - * @return array - */ - protected function filterTunnels(array $tunnels, InputInterface $input) - { - if (!$input->getOption('project') && !$this->getProjectRoot()) { - return $tunnels; - } - - if (!$this->hasSelectedProject()) { - $this->validateInput($input, true); - } - $project = $this->getSelectedProject(); - $environment = $this->hasSelectedEnvironment() ? $this->getSelectedEnvironment() : null; - $appName = $this->hasSelectedEnvironment() ? $this->selectApp($input) : null; - foreach ($tunnels as $key => $tunnel) { - if ($tunnel['projectId'] !== $project->id - || ($environment !== null && $tunnel['environmentId'] !== $environment->id) - || ($appName !== null && $tunnel['appName'] !== $appName)) { - unset($tunnels[$key]); - } - } - - return $tunnels; - } - - /** - * Format a tunnel's relationship as a string. - * - * @param array $tunnel - * - * @return string - */ - protected function formatTunnelRelationship(array $tunnel) - { - return $tunnel['serviceKey'] > 0 - ? sprintf('%s.%d', $tunnel['relationship'], $tunnel['serviceKey']) - : $tunnel['relationship']; - } + protected bool $canBeRunMultipleTimes = false; } diff --git a/src/Command/Tunnel/TunnelInfoCommand.php b/src/Command/Tunnel/TunnelInfoCommand.php index 77e3fa911e..171fb09e2d 100644 --- a/src/Command/Tunnel/TunnelInfoCommand.php +++ b/src/Command/Tunnel/TunnelInfoCommand.php @@ -1,22 +1,37 @@ setName('tunnel:info') - ->setDescription("View relationship info for SSH tunnels") ->addOption('property', 'P', InputOption::VALUE_REQUIRED, 'The relationship property to view') ->addOption('encode', 'c', InputOption::VALUE_NONE, 'Output as base64-encoded JSON'); - $this->addProjectOption(); - $this->addEnvironmentOption(); - $this->addAppOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->selector->addAppOption($this->getDefinition()); + $this->addCompleter($this->selector); // Deprecated options, left for backwards compatibility $this->addHiddenOption('format', null, InputOption::VALUE_REQUIRED, 'DEPRECATED'); @@ -24,28 +39,25 @@ protected function configure() $this->addHiddenOption('no-header', null, InputOption::VALUE_NONE, 'DEPRECATED'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->warnAboutDeprecatedOptions(['columns', 'format', 'no-header']); - - /** @var \Platformsh\Cli\Service\Relationships $relationshipsService */ - $relationshipsService = $this->getService('relationships'); + $this->io->warnAboutDeprecatedOptions(['columns', 'format', 'no-header']); - $tunnels = $this->getTunnelInfo(); + $tunnels = $this->tunnelManager->getTunnels(); $relationships = []; - foreach ($this->filterTunnels($tunnels, $input) as $tunnel) { - $service = $tunnel['service']; + foreach ($this->tunnelManager->filterBySelection($tunnels, $this->selector->getSelection($input)) as $tunnel) { + $service = $tunnel->metadata['service']; // Overwrite the service's address with the local tunnel details. $service = array_merge($service, array_intersect_key([ - 'host' => self::LOCAL_IP, - 'ip' => self::LOCAL_IP, - 'port' => $tunnel['localPort'], + 'host' => TunnelManager::LOCAL_IP, + 'ip' => TunnelManager::LOCAL_IP, + 'port' => $tunnel->localPort, ], $service)); - $service['url'] = $relationshipsService->buildUrl($service); + $service['url'] = $this->relationships->buildUrl($service); - $relationships[$tunnel['relationship']][$tunnel['serviceKey']] = $service; + $relationships[$tunnel->metadata['relationship']][$tunnel->metadata['serviceKey']] = $service; } if (!count($relationships)) { $this->stdErr->writeln('No tunnels found.'); @@ -53,7 +65,7 @@ protected function execute(InputInterface $input, OutputInterface $output) if (count($tunnels) > count($relationships)) { $this->stdErr->writeln(sprintf( 'List all tunnels with: %s tunnels --all', - $this->config()->get('application.executable') + $this->config->getStr('application.executable'), )); } @@ -66,13 +78,10 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - $output->writeln(base64_encode(json_encode($relationships))); + $output->writeln(base64_encode((string) json_encode($relationships))); return 0; } - - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); - $formatter->displayData($output, $relationships, $input->getOption('property')); + $this->propertyFormatter->displayData($output, $relationships, $input->getOption('property')); return 0; } diff --git a/src/Command/Tunnel/TunnelListCommand.php b/src/Command/Tunnel/TunnelListCommand.php index eb2c23abb0..286de0110a 100644 --- a/src/Command/Tunnel/TunnelListCommand.php +++ b/src/Command/Tunnel/TunnelListCommand.php @@ -1,14 +1,23 @@ */ + protected array $tableHeader = [ 'port' => 'Port', 'project' => 'Project', 'environment' => 'Environment', @@ -16,69 +25,73 @@ class TunnelListCommand extends TunnelCommandBase 'relationship' => 'Relationship', 'url' => 'URL', ]; - protected $defaultColumns = ['Port', 'Project', 'Environment', 'App', 'Relationship']; - protected function configure() + /** @var string[] */ + protected array $defaultColumns = ['Port', 'Project', 'Environment', 'App', 'Relationship']; + + public function __construct(private readonly Config $config, private readonly Selector $selector, private readonly Table $table, private readonly TunnelManager $tunnelManager) + { + parent::__construct(); + } + + protected function configure(): void { $this - ->setName('tunnel:list') - ->setAliases(['tunnels']) - ->setDescription('List SSH tunnels') ->addOption('all', 'a', InputOption::VALUE_NONE, 'View all tunnels'); - $this->addProjectOption(); - $this->addEnvironmentOption(); - $this->addAppOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->selector->addAppOption($this->getDefinition()); + $this->addCompleter($this->selector); Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $tunnels = $this->getTunnelInfo(); + $tunnels = $this->tunnelManager->getTunnels(); $allTunnelsCount = count($tunnels); if (!$allTunnelsCount) { $this->stdErr->writeln('No tunnels found.'); return 1; } - $executable = $this->config()->get('application.executable'); + $executable = $this->config->getStr('application.executable'); // Filter tunnels according to the current project and environment, if // available. if (!$input->getOption('all')) { - $tunnels = $this->filterTunnels($tunnels, $input); + $selection = $this->selector->getSelection($input); + $tunnels = $this->tunnelManager->filterBySelection($tunnels, $selection); if (!count($tunnels)) { $this->stdErr->writeln('No tunnels found.'); $this->stdErr->writeln(sprintf( 'List all tunnels with: %s tunnels --all', - $executable + $executable, )); return 1; } } - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); $rows = []; foreach ($tunnels as $tunnel) { $rows[] = [ - 'port' => $tunnel['localPort'], - 'project' => $tunnel['projectId'], - 'environment' => $tunnel['environmentId'], - 'app' => $tunnel['appName'] ?: '[default]', - 'relationship' => $this->formatTunnelRelationship($tunnel), - 'url' => $this->getTunnelUrl($tunnel, $tunnel['service']), + 'port' => $tunnel->localPort, + 'project' => $tunnel->metadata['projectId'], + 'environment' => $tunnel->metadata['environmentId'], + 'app' => $tunnel->metadata['appName'] ?: '[default]', + 'relationship' => $this->tunnelManager->formatRelationship($tunnel), + 'url' => $this->tunnelManager->getUrl($tunnel), ]; } - $table->render($rows, $this->tableHeader, $this->defaultColumns); + $this->table->render($rows, $this->tableHeader, $this->defaultColumns); - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->stdErr->writeln(''); if (!$input->getOption('all') && count($tunnels) < $allTunnelsCount) { $this->stdErr->writeln(sprintf( 'List all tunnels with: %s tunnels --all', - $executable + $executable, )); } diff --git a/src/Command/Tunnel/TunnelOpenCommand.php b/src/Command/Tunnel/TunnelOpenCommand.php index 2aaf99bff4..efb86e4a27 100644 --- a/src/Command/Tunnel/TunnelOpenCommand.php +++ b/src/Command/Tunnel/TunnelOpenCommand.php @@ -1,45 +1,59 @@ setName('tunnel:open') - ->setDescription("Open SSH tunnels to an app's relationships"); $this->addOption('gateway-ports', 'g', InputOption::VALUE_NONE, 'Allow remote hosts to connect to local forwarded ports'); - $this->addProjectOption(); - $this->addEnvironmentOption(); - $this->addAppOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->selector->addAppOption($this->getDefinition()); + $this->addCompleter($this->selector); Ssh::configureInput($this->getDefinition()); - $this->setHelp(<<tunnel:single command can be used on systems without these -extensions. -EOF + $this->setHelp( + <<tunnel:single command can be used on systems without these + extensions. + EOF, ); } - public function isHidden() + public function isHidden(): bool { return parent::isHidden() || OsUtil::isWindows(); } @@ -47,7 +61,7 @@ public function isHidden() /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { if (OsUtil::isWindows()) { $this->stdErr->writeln('This command does not work on Windows, as the required PHP extensions are unavailable.'); @@ -63,36 +77,28 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - $this->chooseEnvFilter = $this->filterEnvsMaybeActive(); - $this->validateInput($input); - $project = $this->getSelectedProject(); - $environment = $this->getSelectedEnvironment(); + $selection = $this->selector->getSelection($input, new SelectorConfig(chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive())); + $environment = $selection->getEnvironment(); - $container = $this->selectRemoteContainer($input, false); - $appName = $container->getName(); + $container = $selection->getRemoteContainer(); $sshUrl = $container->getSshUrl(); - $host = $this->selectHost($input, false, $container); - - /** @var \Platformsh\Cli\Service\Relationships $relationshipsService */ - $relationshipsService = $this->getService('relationships'); - $relationships = $relationshipsService->getRelationships($host); + $host = $this->selector->getHostFromSelection($input, $selection); + $relationships = $this->relationships->getRelationships($host); if (!$relationships) { $this->stdErr->writeln('No relationships found.'); return 1; } if ($environment->is_main) { - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $confirmText = \sprintf('Are you sure you want to open SSH tunnel(s) to the environment %s?', $this->api()->getEnvironmentLabel($environment, 'comment')); - if (!$questionHelper->confirm($confirmText)) { + $confirmText = \sprintf('Are you sure you want to open SSH tunnel(s) to the environment %s?', $this->api->getEnvironmentLabel($environment, 'comment')); + if (!$this->questionHelper->confirm($confirmText)) { return 1; } $this->stdErr->writeln(''); } - $logFile = $this->config()->getWritableUserDir() . '/tunnels.log'; - if (!$log = $this->openLog($logFile)) { + $logFile = $this->config->getWritableUserDir() . '/tunnels.log'; + if (!$log = $this->tunnelManager->openLog($logFile)) { $this->stdErr->writeln(sprintf('Failed to open log file for writing: %s', $logFile)); return 1; } @@ -101,10 +107,7 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($input->getOption('gateway-ports')) { $sshOptions[] = 'GatewayPorts yes'; } - - /** @var \Platformsh\Cli\Service\Ssh $ssh */ - $ssh = $this->getService('ssh'); - $sshArgs = $ssh->getSshArgs($sshUrl, $sshOptions); + $sshArgs = $this->ssh->getSshArgs($sshUrl, $sshOptions); $log->setVerbosity($output->getVerbosity()); @@ -112,46 +115,32 @@ protected function execute(InputInterface $input, OutputInterface $output) // forking in some circumstances. Preload classes that are needed here // to avoid class not found errors later. // TODO find out exactly why this is required - $this->debug('Preloading class before forking: ' . ConsoleTerminateEvent::class); + $this->io->debug('Preloading class before forking: ' . ConsoleTerminateEvent::class); $processManager = new ProcessManager(); $processManager->fork(); $error = false; $processIds = []; - foreach ($relationships as $relationship => $services) { - foreach ($services as $serviceKey => $service) { - $remoteHost = $service['host']; - $remotePort = $service['port']; - - $localPort = $this->getPort(); - $tunnel = [ - 'projectId' => $project->id, - 'environmentId' => $environment->id, - 'appName' => $appName, - 'relationship' => $relationship, - 'serviceKey' => $serviceKey, - 'remotePort' => $remotePort, - 'remoteHost' => $remoteHost, - 'localPort' => $localPort, - 'service' => $service, - 'pid' => null, - ]; - - $relationshipString = $this->formatTunnelRelationship($tunnel); - - if ($openTunnelInfo = $this->isTunnelOpen($tunnel)) { + foreach ($relationships as $name => $services) { + foreach ($services as $key => $service) { + $service['_relationship_name'] = $name; + $service['_relationship_key'] = $key; + $tunnel = $this->tunnelManager->create($selection, $service); + + $relationshipString = $this->tunnelManager->formatRelationship($tunnel); + + if ($openTunnelInfo = $this->tunnelManager->isOpen($tunnel)) { $this->stdErr->writeln(sprintf( 'A tunnel is already opened to the relationship %s, at: %s', $relationshipString, - $this->getTunnelUrl($openTunnelInfo, $service) + $this->tunnelManager->getUrl($openTunnelInfo), )); continue; } - $process = $this->createTunnelProcess($sshUrl, $remoteHost, $remotePort, $localPort, $sshArgs); - - $pidFile = $this->getPidFile($tunnel); + $process = $this->tunnelManager->createProcess($sshUrl, $tunnel, $sshArgs); + $pidFile = $this->tunnelManager->getPidFilename($tunnel); try { $pid = $processManager->startProcess($process, $pidFile, $log); @@ -159,7 +148,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(sprintf( 'Failed to open tunnel for relationship %s: %s', $relationshipString, - $e->getMessage() + $e->getMessage(), )); $error = true; continue; @@ -171,7 +160,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(trim($process->getErrorOutput())); $this->stdErr->writeln(sprintf( 'Failed to open tunnel for relationship: %s', - $relationshipString + $relationshipString, )); unlink($pidFile); $error = true; @@ -179,14 +168,12 @@ protected function execute(InputInterface $input, OutputInterface $output) } // Save information about the tunnel for use in other commands. - $tunnel['pid'] = $pid; - $this->tunnelInfo[] = $tunnel; - $this->saveTunnelInfo(); + $this->tunnelManager->saveNewTunnel($tunnel, $pid); $this->stdErr->writeln(sprintf( 'SSH tunnel opened to %s at: %s', $relationshipString, - $this->getTunnelUrl($tunnel, $service) + $this->tunnelManager->getUrl($tunnel), )); $processIds[] = $pid; @@ -199,8 +186,8 @@ protected function execute(InputInterface $input, OutputInterface $output) } if (!$error) { - $executable = $this->config()->get('application.executable'); - $variable = $this->config()->get('service.env_prefix') . 'RELATIONSHIPS'; + $executable = $this->config->getStr('application.executable'); + $variable = $this->config->getStr('service.env_prefix') . 'RELATIONSHIPS'; $this->stdErr->writeln(''); $this->stdErr->writeln("List tunnels with: $executable tunnels"); $this->stdErr->writeln("View tunnel details with: $executable tunnel:info"); @@ -208,7 +195,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(''); $this->stdErr->writeln( "Save encoded tunnel details to the $variable variable using:" - . "\n export $variable=\"$($executable tunnel:info --encode)\"" + . "\n export $variable=\"$($executable tunnel:info --encode)\"", ); } @@ -224,7 +211,7 @@ protected function execute(InputInterface $input, OutputInterface $output) * * @return string[] */ - private function missingExtensions() + private function missingExtensions(): array { $missing = []; foreach (['pcntl', 'posix'] as $ext) { diff --git a/src/Command/Tunnel/TunnelSingleCommand.php b/src/Command/Tunnel/TunnelSingleCommand.php index ec233914dd..16d974bd54 100644 --- a/src/Command/Tunnel/TunnelSingleCommand.php +++ b/src/Command/Tunnel/TunnelSingleCommand.php @@ -1,29 +1,40 @@ setName('tunnel:single') - ->setDescription('Open a single SSH tunnel to an app relationship') ->addOption('port', null, InputOption::VALUE_REQUIRED, 'The local port'); $this->addOption('gateway-ports', 'g', InputOption::VALUE_NONE, 'Allow remote hosts to connect to local forwarded ports'); - $this->addProjectOption(); - $this->addEnvironmentOption(); - $this->addAppOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->selector->addAppOption($this->getDefinition()); + $this->addCompleter($this->selector); Relationships::configureInput($this->getDefinition()); Ssh::configureInput($this->getDefinition()); } @@ -31,42 +42,34 @@ protected function configure() /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->chooseEnvFilter = $this->filterEnvsMaybeActive(); - $this->validateInput($input); - $project = $this->getSelectedProject(); - $environment = $this->getSelectedEnvironment(); + $selection = $this->selector->getSelection($input, new SelectorConfig(chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive())); + $environment = $selection->getEnvironment(); - $container = $this->selectRemoteContainer($input, false); - $appName = $container->getName(); + $container = $selection->getRemoteContainer(); $sshUrl = $container->getSshUrl(); - $host = $this->selectHost($input, false, $container); - - /** @var \Platformsh\Cli\Service\Relationships $relationshipsService */ - $relationshipsService = $this->getService('relationships'); - $relationships = $relationshipsService->getRelationships($host); + $host = $this->selector->getHostFromSelection($input, $selection); + $relationships = $this->relationships->getRelationships($host); if (!$relationships) { $this->stdErr->writeln('No relationships found.'); return 1; } - $service = $relationshipsService->chooseService($host, $input, $output); + $service = $this->relationships->chooseService($host, $input, $output); if (!$service) { return 1; } if ($environment->is_main) { - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); $confirmText = sprintf( 'Are you sure you want to open an SSH tunnel to' . ' the relationship %s on the' . ' environment %s?', $service['_relationship_name'], - $this->api()->getEnvironmentLabel($environment, false) + $this->api->getEnvironmentLabel($environment, false), ); - if (!$questionHelper->confirm($confirmText)) { + if (!$this->questionHelper->confirm($confirmText)) { return 1; } $this->stdErr->writeln(''); @@ -76,13 +79,7 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($input->getOption('gateway-ports')) { $sshOptions[] = 'GatewayPorts yes'; } - - /** @var \Platformsh\Cli\Service\Ssh $ssh */ - $ssh = $this->getService('ssh'); - $sshArgs = $ssh->getSshArgs($sshUrl, $sshOptions); - - $remoteHost = $service['host']; - $remotePort = $service['port']; + $sshArgs = $this->ssh->getSshArgs($sshUrl, $sshOptions); if ($localPort = $input->getOption('port')) { if (!PortUtil::validatePort($localPort)) { @@ -95,39 +92,26 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - } else { - $localPort = $this->getPort(); } - $tunnel = [ - 'projectId' => $project->id, - 'environmentId' => $environment->id, - 'appName' => $appName, - 'relationship' => $service['_relationship_name'], - 'serviceKey' => $service['_relationship_key'], - 'remotePort' => $remotePort, - 'remoteHost' => $remoteHost, - 'localPort' => $localPort, - 'service' => $service, - 'pid' => null, - ]; - - $relationshipString = $this->formatTunnelRelationship($tunnel); - - if ($openTunnelInfo = $this->isTunnelOpen($tunnel)) { + $tunnel = $this->tunnelManager->create($selection, $service, $localPort); + + $relationshipString = $this->tunnelManager->formatRelationship($tunnel); + + if ($openTunnelInfo = $this->tunnelManager->isOpen($tunnel)) { $this->stdErr->writeln(sprintf( 'A tunnel is already opened to the relationship %s, at: %s', $relationshipString, - $this->getTunnelUrl($openTunnelInfo, $service) + $this->tunnelManager->getUrl($openTunnelInfo), )); return 1; } - $pidFile = $this->getPidFile($tunnel); + $pidFile = $this->tunnelManager->getPidFilename($tunnel); $processManager = new ProcessManager(); - $process = $this->createTunnelProcess($sshUrl, $remoteHost, $remotePort, $localPort, $sshArgs); + $process = $this->tunnelManager->createProcess($sshUrl, $tunnel, $sshArgs); $pid = $processManager->startProcess($process, $pidFile, $output); // Wait a very small time to capture any immediate errors. @@ -136,16 +120,14 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(trim($process->getErrorOutput())); $this->stdErr->writeln(sprintf( 'Failed to open tunnel for relationship: %s', - $relationshipString + $relationshipString, )); unlink($pidFile); return 1; } - $tunnel['pid'] = $pid; - $this->tunnelInfo[] = $tunnel; - $this->saveTunnelInfo(); + $this->tunnelManager->saveNewTunnel($tunnel, $pid); if ($output->isVerbose()) { // Just an extra line for separation from the process manager's log. @@ -155,7 +137,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(sprintf( 'SSH tunnel opened to %s at: %s', $relationshipString, - $this->getTunnelUrl($tunnel, $service) + $this->tunnelManager->getUrl($tunnel), )); $this->stdErr->writeln(''); diff --git a/src/Command/User/UserAddCommand.php b/src/Command/User/UserAddCommand.php index 38f1b12c94..c80767fb94 100644 --- a/src/Command/User/UserAddCommand.php +++ b/src/Command/User/UserAddCommand.php @@ -1,6 +1,18 @@ setName('user:add') - ->setDescription('Add a user to the project') ->addArgument('email', InputArgument::OPTIONAL, "The user's email address"); $this->addRoleOption(); $this->addOption('force-invite', null, InputOption::VALUE_NONE, 'Send an invitation, even if one has already been sent'); - $this->addProjectOption(); - $this->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); $this->addExample('Add Alice as a project admin', 'alice@example.com -r admin'); $this->addExample('Add Bob as a viewer on the "production" environment type, and a contributor on "development" environments', 'bob@example.com -r production:v -r development:c'); @@ -43,7 +67,7 @@ protected function configure() /** * Adds the --role (-r) option to the command. */ - protected function addRoleOption() + protected function addRoleOption(): void { $this->addOption( 'role', @@ -52,28 +76,25 @@ protected function addRoleOption() "The user's project role ('admin' or 'viewer') or environment type role (e.g. 'staging:contributor' or 'production:viewer')." . "\nTo remove a user from an environment type, set the role as 'none'." . "\nThe % or * characters can be used as a wildcard for the environment type, e.g. '%:viewer' to give the user the 'viewer' role on all types." - . "\nThe role can be abbreviated, e.g. 'production:v'." + . "\nThe role can be abbreviated, e.g. 'production:v'.", ); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); - $project = $this->getSelectedProject(); - - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); + $selection = $this->selector->getSelection($input); + $project = $selection->getProject(); $hasOutput = false; - $environmentTypes = $this->api()->getEnvironmentTypes($project); + $environmentTypes = $this->api->getEnvironmentTypes($project); // Process the --role option. $roleInput = ArrayArgument::getOption($input, 'role'); $specifiedProjectRole = $this->getSpecifiedProjectRole($roleInput); $specifiedTypeRoles = $this->getSpecifiedTypeRoles($roleInput, $environmentTypes); if (!empty($roleInput)) { - $specifiedEnvironmentRoles = $this->getSpecifiedEnvironmentRoles($roleInput, $this->api()->getEnvironments($project)); + $specifiedEnvironmentRoles = $this->getSpecifiedEnvironmentRoles($roleInput, $this->api->getEnvironments($project)); } if ($specifiedProjectRole === ProjectUserAccess::ROLE_ADMIN && (!empty($specifiedTypeRoles) || !empty($specifiedEnvironmentRoles))) { $this->warnProjectAdminConflictingRoles(); @@ -88,7 +109,7 @@ protected function execute(InputInterface $input, OutputInterface $output) // In interactive use, error out. In non-interactive use, warn but continue (for backwards compatibility). if ($input->isInteractive()) { // Try to show an example of how to use type-based roles. - $environments = $this->api()->getEnvironments($project); + $environments = $this->api->getEnvironments($project); $converted = $this->convertEnvironmentRolesToTypeRoles($specifiedEnvironmentRoles, $specifiedTypeRoles, $environments, new NullOutput()); if ($converted !== false) { $this->stdErr->writeln(''); @@ -107,7 +128,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(''); // Convert the list of environment-based roles to type-based roles. // Refresh the environments to check their types more accurately. - $environments = $this->api()->getEnvironments($project, true); + $environments = $this->api->getEnvironments($project, true); $converted = $this->convertEnvironmentRolesToTypeRoles($specifiedEnvironmentRoles, $specifiedTypeRoles, $environments, $this->stdErr); if ($converted === false) { return 1; @@ -121,9 +142,9 @@ protected function execute(InputInterface $input, OutputInterface $output) // This can be an email address or a user ID. // When adding a new user, it must be a valid email address. $email = null; - $update = stripos($input->getFirstArgument(), ':u'); + $update = stripos((string) $input->getFirstArgument(), ':u'); if ($emailOrId = $input->getArgument('email')) { - $selection = $this->loadProjectUser($project, $emailOrId); + $selection = $this->accessApi->loadProjectUser($project, $emailOrId); if (!$selection) { if ($update) { throw new InvalidArgumentException('User not found: ' . $emailOrId); @@ -133,18 +154,18 @@ protected function execute(InputInterface $input, OutputInterface $output) throw new InvalidArgumentException('Invalid email address: ' . $emailOrId); } } - } else if (!$input->isInteractive()) { + } elseif (!$input->isInteractive()) { throw new InvalidArgumentException('An email address is required (in non-interactive mode).'); } elseif ($update) { - $userId = $questionHelper->choose($this->listUsers($project), 'Enter a number to choose a user to update:'); + $userId = $this->questionHelper->choose($this->accessApi->listUsers($project), 'Enter a number to choose a user to update:'); $hasOutput = true; - $selection = $this->loadProjectUser($project, $userId); + $selection = $this->accessApi->loadProjectUser($project, $userId); if (!$selection) { throw new InvalidArgumentException('User not found: ' . $userId); } } else { $question = new Question("Enter the user's email address: "); - $question->setValidator(function ($answer) { + $question->setValidator(function (?string $answer) { if (empty($answer)) { throw new InvalidArgumentException('An email address is required.'); } @@ -155,10 +176,10 @@ protected function execute(InputInterface $input, OutputInterface $output) return $filtered; }); $question->setMaxAttempts(5); - $email = $questionHelper->ask($input, $this->stdErr, $question); + $email = $this->questionHelper->ask($input, $this->stdErr, $question); $hasOutput = true; // A user may or may not already exist with this email address. - $selection = $this->loadProjectUser($project, $email); + $selection = $this->accessApi->loadProjectUser($project, $email); } $existingTypeRoles = []; @@ -168,20 +189,20 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($selection instanceof ProjectAccess) { $existingUserId = $selection->id; - $existingUserLabel = $this->getUserLabel($selection, true); + $existingUserLabel = $this->accessApi->getUserLabel($selection, true); $existingProjectRole = $selection->role; $existingTypeRoles = $this->getTypeRoles($selection, $environmentTypes); - $email = $this->legacyUserInfo($selection)['email']; + $email = $this->accessApi->legacyUserInfo($selection)['email']; } elseif ($selection) { $existingUserId = $selection->user_id; - $existingUserLabel = $this->getUserLabel($selection, true); + $existingUserLabel = $this->accessApi->getUserLabel($selection, true); $existingProjectRole = $selection->getProjectRole(); $existingTypeRoles = $selection->getEnvironmentTypeRoles(); $email = $selection->getUserInfo()->email; } if ($existingUserId !== null) { - $this->debug(sprintf('User %s found with user ID: %s', $email, $existingUserId)); + $this->io->debug(sprintf('User %s found with user ID: %s', $email, $existingUserId)); } // Exit if the user is the owner already. @@ -190,7 +211,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(''); } - $this->stdErr->writeln(sprintf('The user %s is the owner of %s.', $existingUserLabel, $this->api()->getProjectLabel($project))); + $this->stdErr->writeln(sprintf('The user %s is the owner of %s.', $existingUserLabel, $this->api->getProjectLabel($project))); if ($specifiedProjectRole || $specifiedTypeRoles) { $this->stdErr->writeln(''); $this->stdErr->writeln("The project owner's role(s) cannot be changed."); @@ -208,11 +229,11 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(''); } - $this->stdErr->writeln(sprintf('Current role(s) of %s on %s:', $existingUserLabel, $this->api()->getProjectLabel($project))); + $this->stdErr->writeln(sprintf('Current role(s) of %s on %s:', $existingUserLabel, $this->api->getProjectLabel($project))); $this->stdErr->writeln(sprintf(' Project role: %s', $existingProjectRole)); if ($existingProjectRole !== ProjectUserAccess::ROLE_ADMIN) { foreach ($environmentTypes as $type) { - $role = isset($existingTypeRoles[$type->id]) ? $existingTypeRoles[$type->id] : '[none]'; + $role = $existingTypeRoles[$type->id] ?? '[none]'; $this->stdErr->writeln(sprintf(' Role on environment type %s: %s', $type->id, $role)); } } @@ -267,9 +288,9 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($desiredTypeRoles) { foreach ($environmentTypes as $environmentType) { $id = $environmentType->id; - $new = isset($desiredTypeRoles[$id]) ? $desiredTypeRoles[$id] : 'none'; + $new = $desiredTypeRoles[$id] ?? 'none'; if ($existingTypeRoles) { - $existing = isset($existingTypeRoles[$id]) ? $existingTypeRoles[$id] : 'none'; + $existing = $existingTypeRoles[$id] ?? 'none'; if ($existing !== $new) { $changesText[] = sprintf(' Role on type %s: %s -> %s', $id, $existing, $new); $typeChanges[$id] = $new; @@ -293,9 +314,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } // Filter out environment type roles of 'none' from the list. - $desiredTypeRoles = array_filter($desiredTypeRoles, function ($role) { - return $role !== 'none'; - }); + $desiredTypeRoles = array_filter($desiredTypeRoles, fn($role): bool => $role !== 'none'); // Add a new line if there has already been output. if ($hasOutput) { @@ -311,8 +330,8 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf( 'To delete the user, run: %s user:delete %s', - $this->config()->get('application.executable'), - OsUtil::escapeShellArg($email) + $this->config->getStr('application.executable'), + OsUtil::escapeShellArg($email), )); } @@ -329,7 +348,7 @@ protected function execute(InputInterface $input, OutputInterface $output) if ($existingUserId !== null) { $this->stdErr->writeln('Summary of changes:'); } else { - $this->stdErr->writeln(sprintf('Adding the user %s to %s:', $email, $this->api()->getProjectLabel($project))); + $this->stdErr->writeln(sprintf('Adding the user %s to %s:', $email, $this->api->getProjectLabel($project))); } foreach ($changesText as $change) { $this->stdErr->writeln(' ' . $change); @@ -338,15 +357,15 @@ protected function execute(InputInterface $input, OutputInterface $output) // Ask for confirmation. if ($existingUserId !== null) { - if (!$questionHelper->confirm('Are you sure you want to make these change(s)?')) { + if (!$this->questionHelper->confirm('Are you sure you want to make these change(s)?')) { return 1; } } else { - if ($this->config()->getWithDefault('warnings.project_users_billing', true)) { + if ($this->config->getBool('warnings.project_users_billing')) { $this->stdErr->writeln('Adding users can result in additional charges.'); $this->stdErr->writeln(''); } - if (!$questionHelper->confirm('Are you sure you want to add this user?')) { + if (!$this->questionHelper->confirm('Are you sure you want to add this user?')) { return 1; } } @@ -366,7 +385,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } catch (AlreadyInvitedException $e) { $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf('An invitation has already been sent to %s', $e->getEmail())); - if ($questionHelper->confirm('Do you want to send this invitation anyway?')) { + if ($this->questionHelper->confirm('Do you want to send this invitation anyway?')) { $project->inviteUserByEmail($email, $desiredProjectRole, [], true, $permissions); $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf('A new invitation has been sent to %s', $email)); @@ -384,12 +403,12 @@ protected function execute(InputInterface $input, OutputInterface $output) } if ($permissions != $selection->permissions) { $this->stdErr->writeln("Updating the user's project access..."); - $this->debug('New permissions: ' . implode(', ', $permissions)); + $this->io->debug('New permissions: ' . implode(', ', $permissions)); $selection->update(['permissions' => $permissions]); $this->stdErr->writeln('Access was updated successfully.'); } else { $this->stdErr->writeln('No changes to make'); - $this->debug('Permissions match: ' . implode(', ', $permissions)); + $this->io->debug('Permissions match: ' . implode(', ', $permissions)); } } elseif ($selection instanceof ProjectAccess) { // Make the desired changes at the project level. @@ -431,14 +450,13 @@ protected function execute(InputInterface $input, OutputInterface $output) } // Wait for activities to complete. - if ($activities && $this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); + if ($activities && $this->activityMonitor->shouldWait($input)) { + $activityMonitor = $this->activityMonitor; if (!$activityMonitor->waitMultiple($activities, $project)) { return 1; } - } elseif (!$this->centralizedPermissionsEnabled()) { - $this->redeployWarning(); + } elseif (!$this->accessApi->centralizedPermissionsEnabled()) { + $this->api->redeployWarning(); } return 0; @@ -449,7 +467,7 @@ protected function execute(InputInterface $input, OutputInterface $output) * * @return string */ - private function validateProjectRole($value) + private function validateProjectRole(string $value): string { return $this->matchRole($value, ProjectUserAccess::$projectRoles); } @@ -459,7 +477,7 @@ private function validateProjectRole($value) * * @return string */ - private function validateEnvironmentRole($value) + private function validateEnvironmentRole(string $value): string { return $this->matchRole($value, array_merge(ProjectUserAccess::$environmentTypeRoles, ['none'])); } @@ -472,10 +490,10 @@ private function validateEnvironmentRole($value) * * @return string */ - private function matchRole($input, array $roles) + private function matchRole(string $input, array $roles): string { foreach ($roles as $role) { - if (strpos($role, strtolower($input)) === 0) { + if (str_starts_with($role, strtolower($input))) { return $role; } } @@ -490,11 +508,9 @@ private function matchRole($input, array $roles) * * @return string */ - private function describeRoles(array $roles) + private function describeRoles(array $roles): string { - $withInitials = array_map(function ($role) { - return sprintf('%s (%s)', $role, substr($role, 0, 1)); - }, $roles); + $withInitials = array_map(fn($role): string => sprintf('%s (%s)', $role, substr((string) $role, 0, 1)), $roles); $last = array_pop($withInitials); return implode(' or ', [implode(', ', $withInitials), $last]); @@ -507,39 +523,32 @@ private function describeRoles(array $roles) * * @return string */ - private function describeRoleInput(array $roles) + private function describeRoleInput(array $roles): string { - return '[' . implode('/', array_map(function ($role) { - return substr($role, 0, 1); - }, $roles)) . ']'; + return '[' . implode('/', array_map(fn($role): string => substr((string) $role, 0, 1), $roles)) . ']'; } /** * Show the form for entering the project role. * - * @param string $defaultRole + * @param string $defaultRole * @param InputInterface $input * * @return string */ - private function showProjectRoleForm($defaultRole, InputInterface $input) + private function showProjectRoleForm(string $defaultRole, InputInterface $input): mixed { - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - $this->stdErr->writeln("The user's project role can be " . $this->describeRoles(ProjectUserAccess::$projectRoles) . '.'); $this->stdErr->writeln(''); $question = new Question( sprintf('Project role (default: %s) %s: ', $defaultRole, $this->describeRoleInput(ProjectUserAccess::$projectRoles)), - $defaultRole + $defaultRole, ); - $question->setValidator(function ($answer) { - return $this->validateProjectRole($answer); - }); + $question->setValidator(fn($answer) => $this->validateProjectRole($answer)); $question->setMaxAttempts(5); $question->setAutocompleterValues(ProjectUserAccess::$projectRoles); - return $questionHelper->ask($input, $this->stdErr, $question); + return $this->questionHelper->ask($input, $this->stdErr, $question); } /** @@ -548,9 +557,9 @@ private function showProjectRoleForm($defaultRole, InputInterface $input) * @param ProjectAccess $projectAccess * @param EnvironmentType[] $environmentTypes * - * @return array + * @return array */ - private function getTypeRoles(ProjectAccess $projectAccess, array $environmentTypes) + private function getTypeRoles(ProjectAccess $projectAccess, array $environmentTypes): array { if ($projectAccess->role === ProjectAccess::ROLE_ADMIN) { return []; @@ -580,18 +589,16 @@ private function getTypeRoles(ProjectAccess $projectAccess, array $environmentTy /** * Show the form for entering environment type roles. * - * @param array $defaultTypeRoles + * @param array $defaultTypeRoles * @param EnvironmentType[] $environmentTypes * @param InputInterface $input * - * @return array + * @return array * The environment type roles (keyed by type ID) including the user's * answers. */ - private function showTypeRolesForm(array $defaultTypeRoles, array $environmentTypes, InputInterface $input) + private function showTypeRolesForm(array $defaultTypeRoles, array $environmentTypes, InputInterface $input): array { - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); $desiredTypeRoles = []; $validRoles = array_merge(ProjectUserAccess::$environmentTypeRoles, ['none']); $this->stdErr->writeln("The user's environment type role(s) can be " . $this->describeRoles($validRoles) . '.'); @@ -599,10 +606,10 @@ private function showTypeRolesForm(array $defaultTypeRoles, array $environmentTy $this->stdErr->writeln(''); foreach ($environmentTypes as $environmentType) { $id = $environmentType->id; - $default = isset($defaultTypeRoles[$id]) ? $defaultTypeRoles[$id] : 'none'; + $default = $defaultTypeRoles[$id] ?? 'none'; $question = new Question( sprintf('Role on type %s (default: %s) %s: ', $id, $default, $initials), - $default + $default, ); $question->setValidator(function ($answer) { if ($answer === 'q' || $answer === 'quit') { @@ -613,7 +620,7 @@ private function showTypeRolesForm(array $defaultTypeRoles, array $environmentTy }); $question->setAutocompleterValues(array_merge($validRoles, ['quit'])); $question->setMaxAttempts(5); - $answer = $questionHelper->ask($input, $this->stdErr, $question); + $answer = $this->questionHelper->ask($input, $this->stdErr, $question); if ($answer === 'q' || $answer === 'quit') { break; } else { @@ -627,16 +634,15 @@ private function showTypeRolesForm(array $defaultTypeRoles, array $environmentTy /** * Extract the specified project role from the list (given in --role). * - * @param array &$roles + * @param string[] $roles * * @return string|null * The project role, or null if none is specified. */ - private function getSpecifiedProjectRole(array &$roles) + private function getSpecifiedProjectRole(array $roles): ?string { - foreach ($roles as $key => $role) { - if (strpos($role, ':') === false) { - unset($roles[$key]); + foreach ($roles as $role) { + if (!str_contains($role, ':')) { return $this->validateProjectRole($role); } } @@ -648,19 +654,19 @@ private function getSpecifiedProjectRole(array &$roles) * Extract the specified environment roles from the list (given in --role). * * @param string[] $roles - * @param array $environments + * @param array $environments * * @return array * An array of environment roles, keyed by environment ID. */ - private function getSpecifiedEnvironmentRoles(array $roles, array $environments) + private function getSpecifiedEnvironmentRoles(array $roles, array $environments): array { $environmentRoles = []; foreach ($roles as $role) { - if (strpos($role, ':') === false) { + if (!str_contains($role, ':')) { continue; } - list($id, $role) = explode(':', $role, 2); + [$id, $role] = explode(':', $role, 2); $role = $this->validateEnvironmentRole($role); // Match environment IDs by wildcard. $matched = Wildcard::select(\array_keys($environments), [$id]); @@ -686,15 +692,15 @@ private function getSpecifiedEnvironmentRoles(array $roles, array $environments) * @return array * An array of environment type roles, keyed by environment type ID. */ - private function getSpecifiedTypeRoles(array &$roles, array $environmentTypes) + private function getSpecifiedTypeRoles(array &$roles, array $environmentTypes): array { $typeRoles = []; - $typeIds = array_map(function (EnvironmentType $type) { return $type->id; }, $environmentTypes); + $typeIds = array_map(fn(EnvironmentType $type) => $type->id, $environmentTypes); foreach ($roles as $key => $role) { - if (strpos($role, ':') === false) { + if (!str_contains($role, ':')) { continue; } - list($id, $role) = explode(':', $role, 2); + [$id, $role] = explode(':', $role, 2); $role = $this->validateEnvironmentRole($role); // Match type IDs by wildcard. $matched = Wildcard::select($typeIds, [$id]); @@ -718,13 +724,13 @@ private function getSpecifiedTypeRoles(array &$roles, array $environmentTypes) * * @param array $specifiedEnvironmentRoles * @param array $specifiedTypeRoles - * @param array $environments + * @param array $environments * @param OutputInterface $stdErr * * @return array|false * A list of environment type roles, keyed by type, or false on failure. */ - private function convertEnvironmentRolesToTypeRoles(array $specifiedEnvironmentRoles, array $specifiedTypeRoles, array $environments, OutputInterface $stdErr) + private function convertEnvironmentRolesToTypeRoles(array $specifiedEnvironmentRoles, array $specifiedTypeRoles, array $environments, OutputInterface $stdErr): false|array { /** @var array> $byType Roles keyed by environment ID, then keyed by type */ $byType = []; @@ -753,7 +759,7 @@ private function convertEnvironmentRolesToTypeRoles(array $specifiedEnvironmentR $role, count($roles), $specifiedTypeRoles[$type], - $type + $type, )); return false; } @@ -761,7 +767,7 @@ private function convertEnvironmentRolesToTypeRoles(array $specifiedEnvironmentR 'The role %s specified on %d environment(s) will be ignored as it is already specified on their type, %s.', $role, count($roles), - $type + $type, )); continue; } @@ -769,14 +775,14 @@ private function convertEnvironmentRolesToTypeRoles(array $specifiedEnvironmentR 'The role %s specified on %d environment(s) will actually be applied to all existing and future environments of the same type, %s.', $role, count($roles), - $type + $type, )); $specifiedTypeRoles[$type] = $role; } return $specifiedTypeRoles; } - private function warnProjectAdminConflictingRoles() + private function warnProjectAdminConflictingRoles(): void { $this->stdErr->writeln('A project admin has administrative access to all environment types.'); $this->stdErr->writeln("To set the user's environment type role(s), set their project role to '" . ProjectUserAccess::ROLE_VIEWER . "'."); diff --git a/src/Command/User/UserDeleteCommand.php b/src/Command/User/UserDeleteCommand.php index 5417643903..7f0f402d2c 100644 --- a/src/Command/User/UserDeleteCommand.php +++ b/src/Command/User/UserDeleteCommand.php @@ -1,52 +1,69 @@ setName('user:delete') - ->setDescription('Delete a user from the project') ->addArgument('email', InputArgument::REQUIRED, "The user's email address"); - $this->addProjectOption()->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); $this->addExample('Delete Alice from the project', 'alice@example.com'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); - $project = $this->getSelectedProject(); + $selection = $this->selector->getSelection($input); + $project = $selection->getProject(); $email = $input->getArgument('email'); - $selection = $this->loadProjectUser($project, $email); + $selection = $this->accessApi->loadProjectUser($project, $email); if (!$selection) { $this->stdErr->writeln("User not found: $email"); return 1; } $userId = $selection instanceof ProjectUserAccess ? $selection->user_id : $selection->id; - $email = $selection instanceof ProjectUserAccess ? $selection->getUserInfo()->email : $this->legacyUserInfo($selection)['email']; + $email = $selection instanceof ProjectUserAccess ? $selection->getUserInfo()->email : $this->accessApi->legacyUserInfo($selection)['email']; if ($project->owner === $userId) { $this->stdErr->writeln(sprintf( 'The user %s is the owner of the project %s.', $email, - $this->api()->getProjectLabel($project, 'error') + $this->api->getProjectLabel($project, 'error'), )); $this->stdErr->writeln("The project's owner cannot be deleted."); return 1; } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - - if (!$questionHelper->confirm("Are you sure you want to delete the user $email?")) { + if (!$this->questionHelper->confirm("Are you sure you want to delete the user $email?")) { return 1; } @@ -54,18 +71,17 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln("User $email deleted"); - if ($result->getActivities() && $this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); + if ($result->getActivities() && $this->activityMonitor->shouldWait($input)) { + $activityMonitor = $this->activityMonitor; $activityMonitor->waitMultiple($result->getActivities(), $project); - } elseif (!$this->centralizedPermissionsEnabled()) { - $this->redeployWarning(); + } elseif (!$this->accessApi->centralizedPermissionsEnabled()) { + $this->api->redeployWarning(); } // If the user was deleting themselves from the project, then invalidate // the projects cache. - if ($this->api()->getMyUserId() === $userId) { - $this->api()->clearProjectsCache(); + if ($this->api->getMyUserId() === $userId) { + $this->api->clearProjectsCache(); } return 0; diff --git a/src/Command/User/UserGetCommand.php b/src/Command/User/UserGetCommand.php index b0f75d3156..a869dbdfb7 100644 --- a/src/Command/User/UserGetCommand.php +++ b/src/Command/User/UserGetCommand.php @@ -1,26 +1,44 @@ setName('user:get') - ->setDescription("View a user's role(s)") ->addArgument('email', InputArgument::OPTIONAL, "The user's email address") - ->addOption('level', 'l', InputOption::VALUE_REQUIRED, "The role level ('project' or 'environment')") + ->addOption('level', 'l', InputOption::VALUE_REQUIRED, "The role level ('project' or 'environment')", null, ['project', 'environment']) ->addOption('pipe', null, InputOption::VALUE_NONE, 'Output the role to stdout (after making any changes)'); - $this->addProjectOption() - ->addEnvironmentOption() - ->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); // Backwards compatibility. $this->setHiddenAliases(['user:role']); @@ -30,7 +48,7 @@ protected function configure() $this->addExample("View Alice's role on the current environment", 'alice@example.com --level environment --pipe'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { if ($input->getOption('role')) { $this->stdErr->writeln('The --role option is no longer available for this command.'); @@ -48,29 +66,27 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - $this->validateInput($input, $level !== 'environment'); - $project = $this->getSelectedProject(); - - $this->warnAboutDeprecatedOptions(['role']); + $selectedUser = $this->selector->getSelection($input, new SelectorConfig(envRequired: $level === 'environment')); + $project = $selectedUser->getProject(); + $environment = $selectedUser->hasEnvironment() ? $selectedUser->getEnvironment() : null; - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); + $this->io->warnAboutDeprecatedOptions(['role']); // Load the user. $email = $input->getArgument('email'); if ($email === null && $input->isInteractive()) { - $email = $questionHelper->choose($this->listUsers($project), 'Enter a number to choose a user:'); + $email = $this->questionHelper->choose($this->accessApi->listUsers($project), 'Enter a number to choose a user:'); } - $selection = $this->loadProjectUser($project, $email); - if (!$selection) { + $selectedUser = $this->accessApi->loadProjectUser($project, $email); + if (!$selectedUser) { $this->stdErr->writeln("User not found: $email"); return 1; } if ($input->getOption('pipe')) { - $this->displayRole($selection, $level, $output); + $this->displayRole($selectedUser, $level, $output, $environment); return 0; } @@ -81,24 +97,19 @@ protected function execute(InputInterface $input, OutputInterface $output) '--project' => $project->id, '--yes' => true, ]; - return $this->runOtherCommand('user:add', $args, $output); + return $this->subCommandRunner->run('user:add', $args, $output); } - /** - * @param ProjectAccess|ProjectUserAccess $user - * @param string $level - * @param OutputInterface $output - */ - private function displayRole($user, $level, OutputInterface $output) + private function displayRole(ProjectAccess|ProjectUserAccess $user, string $level, OutputInterface $output, ?Environment $environment = null): void { if ($level === 'environment') { if ($user instanceof ProjectAccess) { - $access = $this->getSelectedEnvironment()->getUser($user->id); + $access = $environment->getUser($user->id); $currentRole = $access ? $access->role : 'none'; } else { $typeRoles = $user->getEnvironmentTypeRoles(); - $envType = $this->getSelectedEnvironment()->type; - $currentRole = isset($typeRoles[$envType]) ? $typeRoles[$envType] : 'none'; + $envType = $environment->type; + $currentRole = $typeRoles[$envType] ?? 'none'; } } else { $currentRole = $user instanceof ProjectAccess ? $user->role : $user->getProjectRole(); diff --git a/src/Command/User/UserListCommand.php b/src/Command/User/UserListCommand.php index 6b0a9d54d4..71a921f259 100644 --- a/src/Command/User/UserListCommand.php +++ b/src/Command/User/UserListCommand.php @@ -1,14 +1,26 @@ */ + private array $tableHeader = [ 'email' => 'Email address', 'name' => 'Name', 'role' => 'Project role', @@ -16,38 +28,41 @@ class UserListCommand extends UserCommandBase 'granted_at' => 'Granted at', 'updated_at' => 'Updated at', ]; - private $defaultColumns = ['email', 'name', 'role', 'id']; + /** @var string[] */ + private array $defaultColumns = ['email', 'name', 'role', 'id']; - protected function configure() - { - $this - ->setName('user:list') - ->setAliases(['users']) - ->setDescription('List project users'); + public function __construct( + private readonly AccessApi $accessApi, + private readonly Api $api, + private readonly Config $config, + private readonly PropertyFormatter $propertyFormatter, + private readonly Selector $selector, + private readonly Table $table, + ) { + parent::__construct(); + } - if ($this->centralizedPermissionsEnabled()) { + protected function configure(): void + { + if ($this->accessApi->centralizedPermissionsEnabled()) { $this->tableHeader['permissions'] = 'Permissions'; } Table::configureInput($this->getDefinition(), $this->tableHeader, $this->defaultColumns); - $this->addProjectOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); - - $project = $this->getSelectedProject(); + $selection = $this->selector->getSelection($input); - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); + $project = $selection->getProject(); $rows = []; - if ($this->centralizedPermissionsEnabled()) { - $result = ProjectUserAccess::getCollectionWithParent($project->getUri() . '/user-access', $this->api()->getHttpClient(), ['query' => ['page[size]' => 200]]); + if ($this->accessApi->centralizedPermissionsEnabled()) { + $result = ProjectUserAccess::getCollectionWithParent($project->getUri() . '/user-access', $this->api->getHttpClient(), ['query' => ['page[size]' => 200]]); /** @var ProjectUserAccess $item */ foreach ($result['items'] as $item) { $info = $item->getUserInfo(); @@ -56,21 +71,21 @@ protected function execute(InputInterface $input, OutputInterface $output) 'name' => trim(sprintf('%s %s', $info->first_name, $info->last_name)), 'role' => $item->getProjectRole(), 'id' => $item->user_id, - 'permissions' => $formatter->format($item->permissions, 'permissions'), - 'granted_at' => $formatter->format($item->granted_at, 'granted_at'), - 'updated_at' => $formatter->format($item->updated_at, 'updated_at'), + 'permissions' => $this->propertyFormatter->format($item->permissions, 'permissions'), + 'granted_at' => $this->propertyFormatter->format($item->granted_at, 'granted_at'), + 'updated_at' => $this->propertyFormatter->format($item->updated_at, 'updated_at'), ]; } } else { foreach ($project->getUsers() as $projectAccess) { - $info = $this->legacyUserInfo($projectAccess); + $info = $this->accessApi->legacyUserInfo($projectAccess); $rows[] = [ 'email' => $info['email'], 'name' => $info['display_name'], 'role' => $projectAccess->role, 'id' => $projectAccess->id, - 'granted_at' => $formatter->format($info['created_at'], 'granted_at'), - 'updated_at' => $formatter->format($info['updated_at'] ?: $info['created_at'], 'updated_at'), + 'granted_at' => $this->propertyFormatter->format($info['created_at'], 'granted_at'), + 'updated_at' => $this->propertyFormatter->format($info['updated_at'] ?: $info['created_at'], 'updated_at'), ]; } } @@ -89,25 +104,25 @@ protected function execute(InputInterface $input, OutputInterface $output) array_unshift($rows, $ownerRow); } - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->stdErr->writeln(sprintf( 'Users on the project %s:', - $this->api()->getProjectLabel($project) + $this->api->getProjectLabel($project), )); } - $table->render($rows, $this->tableHeader, $this->defaultColumns); + $this->table->render($rows, $this->tableHeader, $this->defaultColumns); - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->stdErr->writeln(''); - $executable = $this->config()->get('application.executable'); + $executable = $this->config->getStr('application.executable'); $this->stdErr->writeln("To add a new user to the project, run: $executable user:add"); $this->stdErr->writeln(''); $this->stdErr->writeln("To view a user's role(s), run: $executable user:get"); $this->stdErr->writeln("To change a user's role(s), run: $executable user:update"); - if ($this->centralizedPermissionsEnabled() && $this->config()->get('api.teams')) { - $organization = $this->api()->getOrganizationById($project->getProperty('organization')); - if (in_array('teams', $organization->capabilities) && $organization->hasLink('members')) { + if ($this->accessApi->centralizedPermissionsEnabled() && $this->config->getBool('api.teams')) { + $organization = $this->api->getOrganizationById($project->getProperty('organization')); + if ($organization && in_array('teams', $organization->capabilities) && $organization->hasLink('members')) { $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf("To list teams with access to the project, run: $executable teams -p %s", $project->id)); } diff --git a/src/Command/User/UserUpdateCommand.php b/src/Command/User/UserUpdateCommand.php index 23a29685ed..41fc11d0e7 100644 --- a/src/Command/User/UserUpdateCommand.php +++ b/src/Command/User/UserUpdateCommand.php @@ -1,24 +1,26 @@ setName('user:update') - ->setDescription('Update user role(s) on a project') - ->addArgument('email', InputArgument::OPTIONAL, "The user's email address"); + $this->addArgument('email', InputArgument::OPTIONAL, "The user's email address"); $this->addRoleOption(); - $this->addProjectOption(); - $this->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); $this->addExample('Make Bob an admin on the "development" and "staging" environment types', 'bob@example.com -r development:a,staging:a'); $this->addExample('Make Charlie a contributor on all environment types', 'charlie@example.com -r %:c'); diff --git a/src/Command/Variable/VariableCreateCommand.php b/src/Command/Variable/VariableCreateCommand.php index 87f1b21bce..eb3e12a495 100644 --- a/src/Command/Variable/VariableCreateCommand.php +++ b/src/Command/Variable/VariableCreateCommand.php @@ -1,42 +1,68 @@ selection = new Selection(); + parent::__construct(); + } - /** - * {@inheritdoc} - */ - protected function configure() + protected function configure(): void { $this - ->setName('variable:create') - ->setDescription('Create a variable') ->addArgument('name', InputArgument::OPTIONAL, 'The variable name') ->addOption('update', 'u', InputOption::VALUE_NONE, 'Update the variable if it already exists'); - $this->form = Form::fromArray($this->getFields()); + $this->form = Form::fromArray($this->variableCommandUtil->getFields(fn() => $this->selection)); $this->form->configureInputDefinition($this->getDefinition()); - $this->addProjectOption() - ->addEnvironmentOption() - ->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input, true); + $selection = $this->selector->getSelection($input, new SelectorConfig(envRequired: false)); + $this->selection = $selection; // Merge the 'name' argument with the --name option. if ($input->getArgument('name')) { @@ -51,34 +77,33 @@ protected function execute(InputInterface $input, OutputInterface $output) // Check whether the variable already exists, if a name is provided. if (($name = $input->getOption('name'))) { if (($prefix = $input->getOption('prefix')) && $prefix !== 'none') { - $name = rtrim($prefix, ':') . ':' . $name; + $name = rtrim((string) $prefix, ':') . ':' . $name; } - $existing = $this->getExistingVariable($name, $this->getRequestedLevel($input), false); + $existing = $this->variableCommandUtil->getExistingVariable($name, $selection, $this->variableCommandUtil->getRequestedLevel($input)); if ($existing) { if (!$input->getOption('update')) { $this->stdErr->writeln('The variable already exists: ' . $name . ''); - $executable = $this->config()->get('application.executable'); - $escapedName = $this->escapeShellArg($name); + $executable = $this->config->getStr('application.executable'); $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf( 'To view the variable, use: %s variable:get %s', $executable, - $escapedName + OsUtil::escapeShellArg($name), )); $this->stdErr->writeln( - 'To skip this check, use the --update (-u) option.' + 'To skip this check, use the --update (-u) option.', ); return 1; } $arguments = [ '--allow-no-change' => true, - '--project' => $this->getSelectedProject()->id, + '--project' => $selection->getProject()->id, 'name' => $name, ]; - if ($this->hasSelectedEnvironment()) { - $arguments['--environment'] = $this->getSelectedEnvironment()->id; + if ($selection->hasEnvironment()) { + $arguments['--environment'] = $selection->getEnvironment()->id; } foreach ($this->form->getFields() as $field) { $argName = '--' . $field->getOptionName(); @@ -87,15 +112,12 @@ protected function execute(InputInterface $input, OutputInterface $output) $arguments[$argName] = $value; } } - return $this->runOtherCommand('variable:update', $arguments, $output); + return $this->subCommandRunner->run('variable:update', $arguments, $output); } } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - try { - $values = $this->form->resolveOptions($input, $output, $questionHelper); + $values = $this->form->resolveOptions($input, $output, $this->questionHelper); } catch (ConditionalFieldException $e) { $previousValues = $e->getPreviousValues(); $field = $e->getField(); @@ -105,7 +127,7 @@ protected function execute(InputInterface $input, OutputInterface $output) 'The option --%s can only be used for variables at the %s level, not at the %s level.', $field->getOptionName(), $conditions['level'], - $previousValues['level'] + $previousValues['level'], )); return 1; } @@ -114,19 +136,24 @@ protected function execute(InputInterface $input, OutputInterface $output) if (isset($values['prefix']) && isset($values['name'])) { if ($values['prefix'] !== 'none') { - $values['name'] = rtrim($values['prefix'], ':') . ':' . $values['name']; + $values['name'] = rtrim((string) $values['prefix'], ':') . ':' . $values['name']; } unset($values['prefix']); } + $environment = null; if (isset($values['environment'])) { - $this->selectEnvironment($values['environment']); + $environment = $this->api->getEnvironment($values['environment'], $selection->getProject()); + if (!$environment) { + $this->stdErr->writeln(sprintf('Environment not found: %s', $values['environment'])); + return 1; + } unset($values['environment']); } // Validate the is_json setting against the value. if (isset($values['value']) && !empty($values['is_json'])) { - if (json_decode($values['value']) === null && json_last_error()) { + if (json_decode((string) $values['value']) === null && json_last_error()) { $this->stdErr->writeln('The value is not valid JSON: ' . $values['value'] . ''); return 1; @@ -135,9 +162,9 @@ protected function execute(InputInterface $input, OutputInterface $output) // Validate the variable name for "env:"-prefixed variables. $envPrefixLength = 4; - if (substr($values['name'], 0, $envPrefixLength) === 'env:' - && !preg_match('/^[a-z][a-z0-9_]*$/i', substr($values['name'], $envPrefixLength))) { - $this->stdErr->writeln('The environment variable name is invalid: ' . substr($values['name'], $envPrefixLength) . ''); + if (substr((string) $values['name'], 0, $envPrefixLength) === 'env:' + && !preg_match('/^[a-z][a-z0-9_]*$/i', substr((string) $values['name'], $envPrefixLength))) { + $this->stdErr->writeln('The environment variable name is invalid: ' . substr((string) $values['name'], $envPrefixLength) . ''); $this->stdErr->writeln('Environment variable names can only contain letters (A-Z), digits (0-9), and underscores. The first character must be a letter.'); return 1; @@ -146,7 +173,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $level = $values['level']; unset($values['level']); - $project = $this->getSelectedProject(); + $project = $selection->getProject(); switch ($level) { case 'environment': @@ -160,26 +187,28 @@ protected function execute(InputInterface $input, OutputInterface $output) unset($values['visible_runtime']); } - $environment = $this->getSelectedEnvironment(); if ($environment->getVariable($values['name'])) { $this->stdErr->writeln(sprintf( 'The variable %s already exists on the environment %s', $values['name'], - $environment->id + $environment->id, )); return 1; } $this->stdErr->writeln(sprintf( - 'Creating variable %s on the environment %s', $values['name'], $environment->id)); + 'Creating variable %s on the environment %s', + $values['name'], + $environment->id, + )); try { - $result = Variable::create($values, $environment->getLink('#manage-variables'), $this->api()->getHttpClient()); + $result = Variable::create($values, $environment->getLink('#manage-variables'), $this->api->getHttpClient()); } catch (BadResponseException $e) { // Explain the error with visible_build on older API versions. - if ($e->getResponse() && $e->getResponse()->getStatusCode() === 400 && !empty($values['visible_build'])) { + if ($e->getResponse()->getStatusCode() === 400 && !empty($values['visible_build'])) { $info = $project->systemInformation(); if (\version_compare($info->version, '12', '<')) { $this->stdErr->writeln(''); @@ -198,7 +227,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(sprintf( 'The variable %s already exists on the project %s', $values['name'], - $this->api()->getProjectLabel($project, 'error') + $this->api->getProjectLabel($project, 'error'), )); return 1; @@ -206,25 +235,24 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(sprintf( 'Creating variable %s on the project %s', $values['name'], - $this->api()->getProjectLabel($project, 'info') + $this->api->getProjectLabel($project, 'info'), )); - $result = ProjectLevelVariable::create($values, $project->getUri() . '/variables', $this->api()->getHttpClient()); + $result = ProjectLevelVariable::create($values, $project->getUri() . '/variables', $this->api->getHttpClient()); break; default: throw new \RuntimeException('Invalid level: ' . $level); } - $this->displayVariable($result->getEntity()); + $this->variableCommandUtil->displayVariable($result->getEntity()); $success = true; - if (!$result->countActivities() || $level === self::LEVEL_PROJECT) { - $this->redeployWarning(); - } elseif ($this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); - $success = $activityMonitor->waitMultiple($result->getActivities(), $this->getSelectedProject()); + if (!$result->countActivities() || $level === VariableCommandUtil::LEVEL_PROJECT) { + $this->api->redeployWarning(); + } elseif ($this->activityMonitor->shouldWait($input)) { + $activityMonitor = $this->activityMonitor; + $success = $activityMonitor->waitMultiple($result->getActivities(), $selection->getProject()); } return $success ? 0 : 1; diff --git a/src/Command/Variable/VariableDeleteCommand.php b/src/Command/Variable/VariableDeleteCommand.php index b4e800bd52..92767a6ede 100644 --- a/src/Command/Variable/VariableDeleteCommand.php +++ b/src/Command/Variable/VariableDeleteCommand.php @@ -1,37 +1,54 @@ setName('variable:delete') - ->addArgument('name', InputArgument::REQUIRED, 'The variable name') - ->setDescription('Delete a variable'); - $this->addLevelOption(); - $this->addProjectOption() - ->addEnvironmentOption() - ->addWaitOptions(); + $this->addArgument('name', InputArgument::REQUIRED, 'The variable name'); + $this->variableCommandUtil->addLevelOption($this->getDefinition()); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); $this->addExample('Delete the variable "example"', 'example'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $level = $this->getRequestedLevel($input); - $this->validateInput($input, $level === self::LEVEL_PROJECT); + $level = $this->variableCommandUtil->getRequestedLevel($input); + $selection = $this->selector->getSelection($input, new SelectorConfig(envRequired: $level !== VariableCommandUtil::LEVEL_PROJECT)); $variableName = $input->getArgument('name'); - $variable = $this->getExistingVariable($variableName, $level); + $variable = $this->variableCommandUtil->getExistingVariable($variableName, $selection, $level); if (!$variable) { return 1; } @@ -41,7 +58,7 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln( "The variable $variableName is inherited," . " so it cannot be deleted from this environment." - . "\nYou could override its value with the variable:update command." + . "\nYou could override its value with the variable:update command.", ); } else { $this->stdErr->writeln("The variable $variableName cannot be deleted"); @@ -50,15 +67,12 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); - - switch ($this->getVariableLevel($variable)) { + switch ($this->variableCommandUtil->getVariableLevel($variable)) { case 'environment': - $environmentId = $this->getSelectedEnvironment()->id; - $confirm = $questionHelper->confirm( + $environmentId = $selection->getEnvironment()->id; + $confirm = $this->questionHelper->confirm( "Are you sure you want to delete the variable $variableName from the environment $environmentId?", - false + false, ); if (!$confirm) { return 1; @@ -66,9 +80,9 @@ protected function execute(InputInterface $input, OutputInterface $output) break; case 'project': - $confirm = $questionHelper->confirm( - "Are you sure you want to delete the variable $variableName from the project " . $this->api()->getProjectLabel($this->getSelectedProject()) . "?", - false + $confirm = $this->questionHelper->confirm( + "Are you sure you want to delete the variable $variableName from the project " . $this->api->getProjectLabel($selection->getProject()) . "?", + false, ); if (!$confirm) { return 1; @@ -81,12 +95,11 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln("Deleted variable $variableName"); $success = true; - if (!$result->countActivities() || $level === self::LEVEL_PROJECT) { - $this->redeployWarning(); - } elseif ($this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); - $success = $activityMonitor->waitMultiple($result->getActivities(), $this->getSelectedProject()); + if (!$result->countActivities() || $level === VariableCommandUtil::LEVEL_PROJECT) { + $this->api->redeployWarning(); + } elseif ($this->activityMonitor->shouldWait($input)) { + $activityMonitor = $this->activityMonitor; + $success = $activityMonitor->waitMultiple($result->getActivities(), $selection->getProject()); } return $success ? 0 : 1; diff --git a/src/Command/Variable/VariableDisableCommand.php b/src/Command/Variable/VariableDisableCommand.php index aa9f41429e..79b8fd1411 100644 --- a/src/Command/Variable/VariableDisableCommand.php +++ b/src/Command/Variable/VariableDisableCommand.php @@ -1,7 +1,14 @@ setName('variable:disable') - ->addArgument('name', InputArgument::REQUIRED, 'The name of the variable') - ->setDescription('Disable an enabled environment-level variable'); + ->addArgument('name', InputArgument::REQUIRED, 'The name of the variable'); $this->setHelp( 'This command is deprecated and will be removed in a future version.' - . "\nInstead, use: variable:update --enabled false [variable]" + . "\nInstead, use: variable:update --enabled false [variable]", ); - $this->addProjectOption() - ->addEnvironmentOption() - ->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); - - return $this->runOtherCommand('variable:update', [ - 'name' => $input->getArgument('name'), - '--enabled' => 'false', - '--project' => $this->getSelectedProject()->id, - '--environment' => $this->getSelectedEnvironment()->id, - ] + array_filter([ - '--wait' => $input->getOption('wait'), - '--no-wait' => $input->getOption('no-wait'), - ])); + $selection = $this->selector->getSelection($input); + + return $this->subCommandRunner->run('variable:update', [ + 'name' => $input->getArgument('name'), + '--enabled' => 'false', + '--project' => $selection->getProject()->id, + '--environment' => $selection->getEnvironment()->id, + ] + array_filter([ + '--wait' => $input->getOption('wait'), + '--no-wait' => $input->getOption('no-wait'), + ])); } } diff --git a/src/Command/Variable/VariableEnableCommand.php b/src/Command/Variable/VariableEnableCommand.php index a76d1aa2df..db99630f78 100644 --- a/src/Command/Variable/VariableEnableCommand.php +++ b/src/Command/Variable/VariableEnableCommand.php @@ -1,7 +1,14 @@ setName('variable:enable') - ->addArgument('name', InputArgument::REQUIRED, 'The name of the variable') - ->setDescription('Enable a disabled environment-level variable'); + ->addArgument('name', InputArgument::REQUIRED, 'The name of the variable'); $this->setHelp( 'This command is deprecated and will be removed in a future version.' - . "\nInstead, use: variable:update --enabled false [variable]" + . "\nInstead, use: variable:update --enabled false [variable]", ); - $this->addProjectOption() - ->addEnvironmentOption() - ->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); - - return $this->runOtherCommand('variable:update', [ - 'name' => $input->getArgument('name'), - '--enabled' => 'true', - '--project' => $this->getSelectedProject()->id, - '--environment' => $this->getSelectedEnvironment()->id, - ] + array_filter([ - '--wait' => $input->getOption('wait'), - '--no-wait' => $input->getOption('no-wait'), - ])); + $selection = $this->selector->getSelection($input); + + return $this->subCommandRunner->run('variable:update', [ + 'name' => $input->getArgument('name'), + '--enabled' => 'true', + '--project' => $selection->getProject()->id, + '--environment' => $selection->getEnvironment()->id, + ] + array_filter([ + '--wait' => $input->getOption('wait'), + '--no-wait' => $input->getOption('no-wait'), + ])); } } diff --git a/src/Command/Variable/VariableGetCommand.php b/src/Command/Variable/VariableGetCommand.php index cb0b29f26a..2fecfc88d1 100644 --- a/src/Command/Variable/VariableGetCommand.php +++ b/src/Command/Variable/VariableGetCommand.php @@ -1,58 +1,82 @@ setName('variable:get') - ->setAliases(['vget']) ->addArgument('name', InputArgument::OPTIONAL, 'The name of the variable') - ->addOption('property', 'P', InputOption::VALUE_REQUIRED, 'View a single variable property') - ->setDescription('View a variable'); - $this->addLevelOption(); + ->addOption('property', 'P', InputOption::VALUE_REQUIRED, 'View a single variable property'); + $this->variableCommandUtil->addLevelOption($this->getDefinition()); Table::configureInput($this->getDefinition()); - $this->addProjectOption() - ->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); $this->addOption('pipe', null, InputOption::VALUE_NONE, '[Deprecated option] Output the variable value only'); $this->addExample('View the variable "example"', 'example'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->warnAboutDeprecatedOptions(['pipe']); - $level = $this->getRequestedLevel($input); - $this->validateInput($input, $level === self::LEVEL_PROJECT); + $this->io->warnAboutDeprecatedOptions(['pipe']); + $level = $this->variableCommandUtil->getRequestedLevel($input); + $selection = $this->selector->getSelection($input, new SelectorConfig(envRequired: $level !== VariableCommandUtil::LEVEL_PROJECT)); $name = $input->getArgument('name'); if ($name) { - $variable = $this->getExistingVariable($name, $level); + $variable = $this->variableCommandUtil->getExistingVariable($name, $selection, $level); if (!$variable) { return 1; } } elseif ($input->isInteractive()) { - $variable = $this->chooseVariable($level); + $variable = $this->chooseVariable($selection, $level); if (!$variable) { $this->stdErr->writeln('No variables found'); return 1; } } else { - return $this->runOtherCommand('variable:list', array_filter([ + return $this->subCommandRunner->run('variable:list', array_filter([ '--level' => $level, - '--project' => $this->getSelectedProject()->id, - '--environment' => $this->hasSelectedEnvironment() ? $this->getSelectedEnvironment()->id : null, + '--project' => $selection->getProject()->id, + '--environment' => $selection->hasEnvironment() ? $selection->getEnvironment()->id : null, '--format' => $input->getOption('format'), ])); } @@ -61,8 +85,8 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(sprintf( "The variable %s is disabled.\nEnable it with: %s variable:enable %s", $variable->name, - $this->config()->get('application.executable'), - escapeshellarg($variable->name) + $this->config->getStr('application.executable'), + escapeshellarg($variable->name), )); } @@ -81,7 +105,7 @@ protected function execute(InputInterface $input, OutputInterface $output) } $properties = $variable->getProperties(); - $properties['level'] = $this->getVariableLevel($variable); + $properties['level'] = $this->variableCommandUtil->getVariableLevel($variable); if ($property = $input->getOption('property')) { if ($property === 'value' && !isset($properties['value']) && $variable->is_sensitive) { @@ -89,51 +113,42 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); + $formatter = $this->propertyFormatter; $formatter->displayData($output, $properties, $property); return 0; } - $this->displayVariable($variable); + $this->variableCommandUtil->displayVariable($variable); - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - - if (!$table->formatIsMachineReadable()) { - $executable = $this->config()->get('application.executable'); - $escapedName = $this->escapeShellArg($name); + if (!$this->table->formatIsMachineReadable()) { + $executable = $this->config->getStr('application.executable'); $this->stdErr->writeln(''); $this->stdErr->writeln(sprintf( 'To list other variables, run: %s variables', - $executable + $executable, )); $this->stdErr->writeln(sprintf( 'To update the variable, use: %s variable:update %s', $executable, - $escapedName + OsUtil::escapeShellArg($name), )); } return 0; } - /** - * @param string|null $level - * - * @return ProjectLevelVariable|EnvironmentLevelVariable|false - */ - private function chooseVariable($level) { + private function chooseVariable(Selection $selection, ?string $level): ProjectLevelVariable|EnvironmentLevelVariable|false + { $projectVariables = []; if ($level === 'project' || $level === null) { - foreach ($this->getSelectedProject()->getVariables() as $variable) { + foreach ($selection->getProject()->getVariables() as $variable) { $projectVariables[$variable->name] = $variable; } } $environmentVariables = []; if ($level === 'environment' || $level === null) { - foreach ($this->getSelectedEnvironment()->getVariables() as $variable) { + foreach ($selection->getEnvironment()->getVariables() as $variable) { $environmentVariables[$variable->name] = $variable; } } @@ -146,11 +161,9 @@ private function chooseVariable($level) { $options[$projectPrefix . $name] = $name . (isset($options[$name]) ? ' (project-level)' : ''); } - /** @var \Platformsh\Cli\Service\QuestionHelper $questionHelper */ - $questionHelper = $this->getService('question_helper'); asort($options, SORT_NATURAL | SORT_FLAG_CASE); - $key = $questionHelper->choose($options, 'Enter a number to choose a variable:'); - if (strpos($key, $projectPrefix) === 0) { + $key = $this->questionHelper->choose($options, 'Enter a number to choose a variable:'); + if (str_starts_with($key, $projectPrefix)) { return $projectVariables[substr($key, strlen($projectPrefix))]; } diff --git a/src/Command/Variable/VariableListCommand.php b/src/Command/Variable/VariableListCommand.php index 5a857f8e07..e30d9df822 100644 --- a/src/Command/Variable/VariableListCommand.php +++ b/src/Command/Variable/VariableListCommand.php @@ -1,52 +1,69 @@ */ + private array $tableHeader = [ 'name' => 'Name', 'level' => 'Level', 'value' => 'Value', 'is_enabled' => 'Enabled', ]; - /** - * {@inheritdoc} - */ - protected function configure() + public function __construct( + private readonly Api $api, + private readonly Config $config, + private readonly PropertyFormatter $propertyFormatter, + private readonly Selector $selector, + private readonly Table $table, + private readonly VariableCommandUtil $variableCommandUtil, + ) { + parent::__construct(); + } + + protected function configure(): void { - $this - ->setName('variable:list') - ->setAliases(['variables', 'var']) - ->setDescription('List variables'); - $this->addLevelOption(); + $this->variableCommandUtil->addLevelOption($this->getDefinition()); Table::configureInput($this->getDefinition(), $this->tableHeader); - $this->addProjectOption() - ->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $level = $this->getRequestedLevel($input); + $level = $this->variableCommandUtil->getRequestedLevel($input); - $this->validateInput($input, $level === 'project'); + $selection = $this->selector->getSelection($input, new SelectorConfig(envRequired: $level !== 'project')); - $project = $this->getSelectedProject(); - - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); + $project = $selection->getProject(); $variables = []; if ($level === 'project' || $level === null) { $variables = array_merge($variables, $project->getVariables()); } if ($level === 'environment' || $level === null) { - $variables = array_merge($variables, $this->getSelectedEnvironment()->getVariables()); + $variables = array_merge($variables, $selection->getEnvironment()->getVariables()); } if (empty($variables)) { @@ -55,20 +72,20 @@ protected function execute(InputInterface $input, OutputInterface $output) return 1; } - if (!$table->formatIsMachineReadable()) { - $projectLabel = $this->api()->getProjectLabel($project); + if (!$this->table->formatIsMachineReadable()) { + $projectLabel = $this->api->getProjectLabel($project); switch ($level) { case 'project': $this->stdErr->writeln(sprintf('Project-level variables on the project %s:', $projectLabel)); break; case 'environment': - $environmentId = $this->getSelectedEnvironment()->id; + $environmentId = $selection->getEnvironment()->id; $this->stdErr->writeln(sprintf('Environment-level variables on the environment %s of project %s:', $environmentId, $projectLabel)); break; default: - $environmentId = $this->getSelectedEnvironment()->id; + $environmentId = $selection->getEnvironment()->id; $this->stdErr->writeln(sprintf('Variables on the project %s, environment %s:', $projectLabel, $environmentId)); break; } @@ -76,48 +93,44 @@ protected function execute(InputInterface $input, OutputInterface $output) $rows = []; - /** @var \Platformsh\Client\Model\ProjectLevelVariable|\Platformsh\Client\Model\Variable $variable */ + /** @var ProjectLevelVariable|Variable $variable */ foreach ($variables as $variable) { $row = []; $row['name'] = $variable->name; - $row['level'] = new AdaptiveTableCell($this->getVariableLevel($variable), ['wrap' => false]); + $row['level'] = new AdaptiveTableCell($this->variableCommandUtil->getVariableLevel($variable), ['wrap' => false]); // Handle sensitive variables' value (it isn't exposed in the API). if (!$variable->hasProperty('value', false) && $variable->is_sensitive) { - $row['value'] = $table->formatIsMachineReadable() ? '' : '[Hidden: sensitive value]'; + $row['value'] = $this->table->formatIsMachineReadable() ? '' : '[Hidden: sensitive value]'; } else { - $row['value'] = $variable->value; + $row['value'] = $this->propertyFormatter->format($variable->value, 'value'); } - if ($variable->hasProperty('is_enabled')) { - $row['is_enabled'] = $variable->is_enabled ? 'true' : 'false'; - } else { - $row['is_enabled'] = ''; - } + $row['is_enabled'] = $this->propertyFormatter->format($variable->getProperty('is_enabled', false), 'is_enabled'); $rows[] = $row; } - $table->render($rows, $this->tableHeader); + $this->table->render($rows, $this->tableHeader); - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->stdErr->writeln(''); - $executable = $this->config()->get('application.executable'); + $executable = $this->config->getStr('application.executable'); $this->stdErr->writeln(sprintf( 'To view variable details, run: %s variable:get [name]', - $executable + $executable, )); $this->stdErr->writeln(sprintf( 'To create a new variable, run: %s variable:create', - $executable + $executable, )); $this->stdErr->writeln(sprintf( 'To update a variable, run: %s variable:update [name]', - $executable + $executable, )); $this->stdErr->writeln(sprintf( 'To delete a variable, run: %s variable:delete [name]', - $executable + $executable, )); } diff --git a/src/Command/Variable/VariableSetCommand.php b/src/Command/Variable/VariableSetCommand.php index f34caab449..129c385274 100644 --- a/src/Command/Variable/VariableSetCommand.php +++ b/src/Command/Variable/VariableSetCommand.php @@ -1,7 +1,14 @@ setName('variable:set') - ->setAliases(['vset']) ->addArgument('name', InputArgument::REQUIRED, 'The variable name') ->addArgument('value', InputArgument::REQUIRED, 'The variable value') ->addOption('json', null, InputOption::VALUE_NONE, 'Mark the value as JSON') - ->addOption('disabled', null, InputOption::VALUE_NONE, 'Mark the variable as disabled') - ->setDescription('Set a variable for an environment'); - $this->addProjectOption() - ->addEnvironmentOption() - ->addWaitOptions(); + ->addOption('disabled', null, InputOption::VALUE_NONE, 'Mark the variable as disabled'); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); $this->setHelp( 'This command is deprecated and will be removed in a future version.' - . "\nInstead, use variable:create and variable:update" + . "\nInstead, use variable:create and variable:update", ); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input); + $selection = $this->selector->getSelection($input); $variableName = $input->getArgument('name'); $variableValue = $input->getArgument('value'); @@ -53,7 +61,7 @@ protected function execute(InputInterface $input, OutputInterface $output) // Check whether the variable already exists. If there is no change, // quit early. - $existing = $this->getSelectedEnvironment() + $existing = $selection->getEnvironment() ->getVariable($variableName); if ($existing && $existing->value === $variableValue @@ -65,29 +73,23 @@ protected function execute(InputInterface $input, OutputInterface $output) } // Set the variable to a new value. - $result = $this->getSelectedEnvironment() + $result = $selection->getEnvironment() ->setVariable($variableName, $variableValue, $json, $enabled); $this->stdErr->writeln("Variable $variableName set to: $variableValue"); $success = true; if (!$result->countActivities()) { - $this->redeployWarning(); - } elseif ($this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); - $success = $activityMonitor->waitMultiple($result->getActivities(), $this->getSelectedProject()); + $this->api->redeployWarning(); + } elseif ($this->activityMonitor->shouldWait($input)) { + $activityMonitor = $this->activityMonitor; + $success = $activityMonitor->waitMultiple($result->getActivities(), $selection->getProject()); } return $success ? 0 : 1; } - /** - * @param $string - * - * @return bool - */ - protected function validateJson($string) + protected function validateJson(string $string): bool { if ($string === 'null') { return true; diff --git a/src/Command/Variable/VariableUpdateCommand.php b/src/Command/Variable/VariableUpdateCommand.php index 0ea7e7a14d..60b28fa717 100644 --- a/src/Command/Variable/VariableUpdateCommand.php +++ b/src/Command/Variable/VariableUpdateCommand.php @@ -1,45 +1,63 @@ selection = new Selection(); + parent::__construct(); + } - /** - * {@inheritdoc} - */ - protected function configure() + protected function configure(): void { $this - ->setName('variable:update') - ->setDescription('Update a variable') ->addArgument('name', InputArgument::REQUIRED, 'The variable name') ->addOption('allow-no-change', null, InputOption::VALUE_NONE, 'Return success (zero exit code) if no changes were provided'); - $this->addLevelOption(); - $fields = $this->getFields(); + $this->variableCommandUtil->addLevelOption($this->getDefinition()); + $fields = $this->variableCommandUtil->getFields(fn() => $this->selection); unset($fields['name'], $fields['prefix'], $fields['environment'], $fields['level']); $this->form = Form::fromArray($fields); $this->form->configureInputDefinition($this->getDefinition()); - $this->addProjectOption() - ->addEnvironmentOption() - ->addWaitOptions(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); + $this->activityMonitor->addWaitOptions($this->getDefinition()); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $level = $this->getRequestedLevel($input); - $this->validateInput($input, $level === self::LEVEL_PROJECT); + $level = $this->variableCommandUtil->getRequestedLevel($input); + $selection = $this->selector->getSelection($input, new SelectorConfig(envRequired: $level !== VariableCommandUtil::LEVEL_PROJECT)); + $this->selection = $selection; $name = $input->getArgument('name'); - $variable = $this->getExistingVariable($name, $level); + $variable = $this->variableCommandUtil->getExistingVariable($name, $selection, $level); if (!$variable) { return 1; } @@ -66,8 +84,8 @@ protected function execute(InputInterface $input, OutputInterface $output) // Validate the is_json setting against the value. if ((isset($variable->value) || isset($values['value'])) && (!empty($values['is_json']) || $variable->is_json)) { - $value = isset($values['value']) ? $values['value'] : $variable->value; - if (json_decode($value) === null && json_last_error()) { + $value = $values['value'] ?? $variable->value; + if (json_decode((string) $value) === null && json_last_error()) { $this->stdErr->writeln('The value is not valid JSON: ' . $value . ''); return 1; @@ -83,16 +101,15 @@ protected function execute(InputInterface $input, OutputInterface $output) $result = $variable->update($values); $this->stdErr->writeln("Variable {$variable->name} updated"); - $this->displayVariable($variable); + $this->variableCommandUtil->displayVariable($variable); $success = true; - if (!$result->countActivities() || $level === self::LEVEL_PROJECT) { - $this->redeployWarning(); - } elseif ($this->shouldWait($input)) { - /** @var \Platformsh\Cli\Service\ActivityMonitor $activityMonitor */ - $activityMonitor = $this->getService('activity_monitor'); - $success = $activityMonitor->waitMultiple($result->getActivities(), $this->getSelectedProject()); + if (!$result->countActivities() || $level === VariableCommandUtil::LEVEL_PROJECT) { + $this->api->redeployWarning(); + } elseif ($this->activityMonitor->shouldWait($input)) { + $activityMonitor = $this->activityMonitor; + $success = $activityMonitor->waitMultiple($result->getActivities(), $selection->getProject()); } return $success ? 0 : 1; diff --git a/src/Command/Version/VersionListCommand.php b/src/Command/Version/VersionListCommand.php index 10baf5c7fc..3414d3bdbd 100644 --- a/src/Command/Version/VersionListCommand.php +++ b/src/Command/Version/VersionListCommand.php @@ -1,39 +1,46 @@ setName('version:list') - ->setAliases(['versions']) - ->setDescription('List environment versions'); - $this->addProjectOption(); - $this->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); Table::configureInput($this->getDefinition()); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->validateInput($input, false, true); - $environment = $this->getSelectedEnvironment(); - - $httpClient = $this->api()->getHttpClient(); - $data = $httpClient->get($environment->getLink('#versions'))->json(); + $selection = $this->selector->getSelection($input, new SelectorConfig(selectDefaultEnv: true)); + $environment = $selection->getEnvironment(); - /** @var Table $table */ - $table = $this->getService('table'); - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); + $httpClient = $this->api->getHttpClient(); + $response = $httpClient->get($environment->getLink('#versions')); + $data = (array) Utils::jsonDecode((string) $response->getBody(), true); $header = ['id' => 'ID', 'commit' => 'Commit', 'locked' => 'Locked', 'routing_percentage' => 'Routing %']; @@ -42,20 +49,20 @@ protected function execute(InputInterface $input, OutputInterface $output) $rows[] = [ 'id' => new AdaptiveTableCell($versionData['id'], ['wrap' => false]), 'commit' => $versionData['commit'], - 'locked' => $formatter->format($versionData['locked'], 'locked'), + 'locked' => $this->propertyFormatter->format($versionData['locked'], 'locked'), 'routing_percentage' => $versionData['routing']['percentage'], ]; } - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->stdErr->writeln(sprintf( 'Versions for the project %s, environment %s:', - $this->api()->getProjectLabel($this->getSelectedProject()), - $this->api()->getEnvironmentLabel($environment) + $this->api->getProjectLabel($selection->getProject()), + $this->api->getEnvironmentLabel($environment), )); } - $table->render($rows, $header); + $this->table->render($rows, $header); return 0; } diff --git a/src/Command/WebCommand.php b/src/Command/WebCommand.php index 68d403f340..ed4660942d 100644 --- a/src/Command/WebCommand.php +++ b/src/Command/WebCommand.php @@ -1,36 +1,46 @@ config()->has('service.console_url'); - $this - ->setName('web') - ->setDescription($hasConsole ? 'Open the project in the Web Console' : 'Open the project in the Web UI'); Url::configureInput($this->getDefinition()); - $this->addProjectOption() - ->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { // Attempt to select the appropriate project and environment. try { - $this->validateInput($input, true); - $environmentId = $this->hasSelectedEnvironment() ? $this->getSelectedEnvironment()->id : null; + $selection = $this->selector->getSelection($input, new SelectorConfig(envRequired: false)); + $environmentId = $selection->hasEnvironment() ? $selection->getEnvironment()->id : null; } catch (\Exception $e) { // If a project has been specified but is not found, then error out. - if ($input->getOption('project') && !$this->hasSelectedProject()) { + if ($input->getOption('project')) { throw $e; } + $selection = new Selection(); // If an environment ID has been specified but not found, then use // the specified ID anyway. This allows building a URL when an @@ -38,31 +48,28 @@ protected function execute(InputInterface $input, OutputInterface $output) $environmentId = $input->getOption('environment'); } - if ($this->hasSelectedProject()) { - $project = $this->getSelectedProject(); - $url = $this->api()->getConsoleURL($project); + if ($selection->hasProject()) { + $project = $selection->getProject(); + $url = $this->api->getConsoleURL($project); if ($environmentId !== null) { // Console links lack the /environments path component. - $isConsole = $this->config()->has('detection.console_domain') - && parse_url($url, PHP_URL_HOST) === $this->config()->get('detection.console_domain'); + $isConsole = $this->config->has('detection.console_domain') + && parse_url($url, PHP_URL_HOST) === $this->config->getStr('detection.console_domain'); if ($isConsole) { - $url .= '/' . rawurlencode($environmentId); + $url .= '/' . rawurlencode((string) $environmentId); } else { - $url .= '/environments/' . rawurlencode($environmentId); + $url .= '/environments/' . rawurlencode((string) $environmentId); } } - } elseif ($this->config()->has('service.console_url')) { - $url = $this->config()->get('service.console_url'); - } elseif ($this->config()->has('service.accounts_url')) { - $url = $this->config()->get('service.accounts_url'); + } elseif ($this->config->has('service.console_url')) { + $url = $this->config->getStr('service.console_url'); + } elseif ($this->config->has('service.accounts_url')) { + $url = $this->config->getStr('service.accounts_url'); } else { $this->stdErr->writeln('No URLs are configured'); return 1; } - - /** @var \Platformsh\Cli\Service\Url $urlService */ - $urlService = $this->getService('url'); - $urlService->openUrl($url); + $this->url->openUrl($url); return 0; } diff --git a/src/Command/WelcomeCommand.php b/src/Command/WelcomeCommand.php index 8152e3deea..5125a2534c 100644 --- a/src/Command/WelcomeCommand.php +++ b/src/Command/WelcomeCommand.php @@ -1,46 +1,72 @@ setName('welcome') - ->setDescription('Welcome to ' . $this->config()->get('service.name')); + $envPrefix = $this->config->getStr('service.env_prefix'); + $projectId = getenv($envPrefix . 'PROJECT'); + $environmentId = getenv($envPrefix . 'BRANCH'); + if ($projectId && $environmentId) { + return [ + 'projectId' => $projectId, + 'environmentId' => $environmentId, + 'appName' => getenv($envPrefix . 'APPLICATION_NAME') ?: '', + ]; + } + return null; } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->stdErr->writeln("Welcome to " . $this->config()->get('service.name') . "!\n"); + $this->stdErr->writeln("Welcome to " . $this->config->getStr('service.name') . "!\n"); - $envPrefix = $this->config()->get('service.env_prefix'); - $onContainer = getenv($envPrefix . 'PROJECT') && getenv($envPrefix . 'BRANCH'); + $containerEnv = $this->containerEnvironment(); - if ($project = $this->getCurrentProject()) { + if ($project = $this->selector->getCurrentProject()) { $this->welcomeForLocalProjectDir($project); - } elseif ($onContainer) { - $this->welcomeOnContainer(); + } elseif ($containerEnv) { + $this->welcomeOnContainer($containerEnv); } else { $this->defaultWelcome(); } - $executable = $this->config()->get('application.executable'); + $executable = $this->config->getStr('application.executable'); - $this->showSessionInfo(); + $this->api->showSessionInfo(); - if ($this->api()->isLoggedIn() && !$this->config()->getWithDefault('ssh.auto_load_cert', false)) { - /** @var \Platformsh\Cli\Service\SshKey $sshKey */ - $sshKey = $this->getService('ssh_key'); + if ($this->api->isLoggedIn() && !$this->config->getBool('ssh.auto_load_cert')) { + $sshKey = $this->sshKey; if (!$sshKey->hasLocalKey()) { $this->stdErr->writeln(''); $this->stdErr->writeln("To add an SSH key, run: $executable ssh-key:add"); @@ -49,87 +75,86 @@ protected function execute(InputInterface $input, OutputInterface $output) $this->stdErr->writeln(''); $this->stdErr->writeln("To view all commands, run: $executable list"); + return 0; } /** * Display default welcome message, when not in a project directory. */ - private function defaultWelcome() + private function defaultWelcome(): void { // The project is not known. Show all projects. - $this->runOtherCommand('projects', ['--refresh' => '0']); + $this->subCommandRunner->run('projects', ['--refresh' => '0']); } /** * Display welcome for a local project directory. * - * @param \Platformsh\Client\Model\Project $project + * @param Project $project */ - private function welcomeForLocalProjectDir(Project $project) + private function welcomeForLocalProjectDir(Project $project): void { - $this->stdErr->writeln("Project: " . $this->api()->getProjectLabel($project)); - if ($this->config()->get('api.organizations')) { - $org = $this->api()->getOrganizationById($project->getProperty('organization')); + $this->stdErr->writeln("Project: " . $this->api->getProjectLabel($project)); + if ($this->config->getBool('api.organizations')) { + $org = $this->api->getOrganizationById($project->getProperty('organization')); if ($org) { - $this->stdErr->writeln("Organization: " . $this->api()->getOrganizationLabel($org)); + $this->stdErr->writeln("Organization: " . $this->api->getOrganizationLabel($org)); } } - $this->stdErr->writeln("Console URL: " . $this->api()->getConsoleURL($project) . "\n"); + $this->stdErr->writeln("Console URL: " . $this->api->getConsoleURL($project) . "\n"); if ($project->isSuspended()) { - $this->warnIfSuspended($project); + $this->api->warnIfSuspended($project); } else { // Show the environments. - $this->runOtherCommand('environments', [ + $this->subCommandRunner->run('environments', [ '--project' => $project->id, ]); } - $executable = $this->config()->get('application.executable'); + $executable = $this->config->getStr('application.executable'); $this->stdErr->writeln("\nYou can list other projects by running $executable projects"); } /** * Display welcome when the user is in a cloud container environment. + * + * @param array{projectId: string, environmentId: string, appName: string} $containerEnvironment */ - private function welcomeOnContainer() + private function welcomeOnContainer(array $containerEnvironment): void { - $envPrefix = $this->config()->get('service.env_prefix'); - $executable = $this->config()->get('application.executable'); - - $projectId = getenv($envPrefix . 'PROJECT'); - $environmentId = getenv($envPrefix . 'BRANCH'); - $appName = getenv($envPrefix . 'APPLICATION_NAME'); + $envPrefix = $this->config->getStr('service.env_prefix'); + $executable = $this->config->getStr('application.executable'); $project = false; $environment = false; - if ($this->api()->isLoggedIn()) { - $project = $this->api()->getProject($projectId); - if ($project && $environmentId) { - $environment = $this->api()->getEnvironment($environmentId, $project); + if ($this->api->isLoggedIn()) { + $project = $this->api->getProject($containerEnvironment['projectId']); + if ($project) { + $environment = $this->api->getEnvironment($containerEnvironment['environmentId'], $project); } } if ($project) { - $this->stdErr->writeln('Project: ' . $this->api()->getProjectLabel($project)); + $this->stdErr->writeln('Project: ' . $this->api->getProjectLabel($project)); if ($environment) { - $this->stdErr->writeln('Environment: ' . $this->api()->getEnvironmentLabel($environment)); + $this->stdErr->writeln('Environment: ' . $this->api->getEnvironmentLabel($environment)); } - if ($appName) { - $this->stdErr->writeln('Application name: ' . $appName . ''); + if ($containerEnvironment['appName']) { + $this->stdErr->writeln('Application name: ' . $containerEnvironment['appName'] . ''); } if ($project->isSuspended()) { - $this->warnIfSuspended($project); + $this->api->warnIfSuspended($project); return; } } else { - $this->stdErr->writeln('Project ID: ' . $projectId . ''); - if ($environmentId) { - $this->stdErr->writeln('Environment ID: ' . $environmentId . ''); + $this->stdErr->writeln('Project ID: ' . $containerEnvironment['projectId'] . ''); + if ($containerEnvironment['environmentId']) { + $this->stdErr->writeln('Environment ID: ' . $containerEnvironment['environmentId'] . ''); } - if ($appName) { - $this->stdErr->writeln('Application name: ' . $appName . ''); + if ($containerEnvironment['appName']) { + $this->stdErr->writeln('Application name: ' . $containerEnvironment['appName'] . ''); } } diff --git a/src/Command/WinkyCommand.php b/src/Command/WinkyCommand.php index d86c03d977..544455187e 100644 --- a/src/Command/WinkyCommand.php +++ b/src/Command/WinkyCommand.php @@ -1,28 +1,31 @@ setName('winky'); + parent::__construct(); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $winky = new Winky($output, $this->config()->get('service.name')); + $winky = new Winky($output, $this->config->getStr('service.name')); if (!$output->isDecorated()) { $winky->render(); - return; + return 0; } while (true) { diff --git a/src/Command/Worker/WorkerListCommand.php b/src/Command/Worker/WorkerListCommand.php index 1ccfd46468..6320da844a 100644 --- a/src/Command/Worker/WorkerListCommand.php +++ b/src/Command/Worker/WorkerListCommand.php @@ -1,42 +1,53 @@ */ + private array $tableHeader = ['Name', 'Type', 'Commands']; - /** - * {@inheritdoc} - */ - protected function configure() + public function __construct(private readonly Api $api, private readonly Config $config, private readonly PropertyFormatter $propertyFormatter, private readonly Selector $selector, private readonly Table $table) { - $this->setName('worker:list') - ->setAliases(['workers']) - ->setDescription('Get a list of all deployed workers') + parent::__construct(); + } + + protected function configure(): void + { + $this ->addOption('refresh', null, InputOption::VALUE_NONE, 'Whether to refresh the cache') ->addOption('pipe', null, InputOption::VALUE_NONE, 'Output a list of worker names only'); - $this->addProjectOption() - ->addEnvironmentOption(); + $this->selector->addProjectOption($this->getDefinition()); + $this->selector->addEnvironmentOption($this->getDefinition()); + $this->addCompleter($this->selector); Table::configureInput($this->getDefinition(), $this->tableHeader); } /** * {@inheritdoc} */ - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { - $this->chooseEnvFilter = $this->filterEnvsMaybeActive(); - $this->validateInput($input); + $selection = $this->selector->getSelection($input, new SelectorConfig(chooseEnvFilter: SelectorConfig::filterEnvsMaybeActive())); - $deployment = $this->api() - ->getCurrentDeployment($this->getSelectedEnvironment(), $input->getOption('refresh')); + $deployment = $this->api + ->getCurrentDeployment($selection->getEnvironment(), $input->getOption('refresh')); $workers = $deployment->workers; if (empty($workers)) { @@ -53,56 +64,50 @@ protected function execute(InputInterface $input, OutputInterface $output) return 0; } - - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); $rows = []; foreach ($workers as $worker) { - $commands = isset($worker->worker['commands']) ? $worker->worker['commands'] : []; - $rows[] = [$worker->name, $formatter->format($worker->type, 'service_type'), $formatter->format($commands)]; + $commands = $worker->worker['commands'] ?? []; + $rows[] = [$worker->name, $this->propertyFormatter->format($worker->type, 'service_type'), $this->propertyFormatter->format($commands)]; } - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->stdErr->writeln(sprintf( 'Workers on the project %s, environment %s:', - $this->api()->getProjectLabel($this->getSelectedProject()), - $this->api()->getEnvironmentLabel($this->getSelectedEnvironment()) + $this->api->getProjectLabel($selection->getProject()), + $this->api->getEnvironmentLabel($selection->getEnvironment()), )); } - $table->render($rows, $this->tableHeader); + $this->table->render($rows, $this->tableHeader); - if (!$table->formatIsMachineReadable()) { + if (!$this->table->formatIsMachineReadable()) { $this->recommendOtherCommands($deployment); } return 0; } - private function recommendOtherCommands(EnvironmentDeployment $deployment) + private function recommendOtherCommands(EnvironmentDeployment $deployment): void { $lines = []; - $executable = $this->config()->get('application.executable'); + $executable = $this->config->getStr('application.executable'); if ($deployment->webapps) { $lines[] = sprintf( 'To list applications, run: %s apps', - $executable + $executable, ); } if ($deployment->services) { $lines[] = sprintf( 'To list services, run: %s services', - $executable + $executable, ); } if ($info = $deployment->getProperty('project_info', false)) { - if (!empty($info['settings']['sizing_api_enabled']) && $this->config()->get('api.sizing') && $this->config()->isCommandEnabled('resources:set')) { + if (!empty($info['settings']['sizing_api_enabled']) && $this->config->getBool('api.sizing') && $this->config->isCommandEnabled('resources:set')) { $lines[] = sprintf( "To configure resources, run: %s resources:set", - $executable + $executable, ); } } diff --git a/src/Console/AdaptiveTable.php b/src/Console/AdaptiveTable.php index 22c419c1b8..51b03f3b58 100644 --- a/src/Console/AdaptiveTable.php +++ b/src/Console/AdaptiveTable.php @@ -1,5 +1,7 @@ |TableSeparator> */ + protected array $rowsCopy = []; + /** @var array> */ + protected array $headersCopy = []; /** * AdaptiveTable constructor. * - * @param OutputInterface $output - * @param int|null $maxTableWidth - * @param int|null $minColumnWidth + * @param OutputInterface $outputCopy + * @param int|null $maxTableWidth + * @param int $minColumnWidth */ - public function __construct(OutputInterface $output, $maxTableWidth = null, $minColumnWidth = 10) + public function __construct(protected OutputInterface $outputCopy, ?int $maxTableWidth = null, protected int $minColumnWidth = 10) { - $this->outputCopy = $output; $this->maxTableWidth = $maxTableWidth !== null ? $maxTableWidth : (new Terminal())->getWidth(); - $this->minColumnWidth = $minColumnWidth; - parent::__construct($output); + parent::__construct($this->outputCopy); } /** * {@inheritdoc} * * Overrides Table->addRow() so the row content can be accessed. + * + * @param TableSeparator|array $row */ - public function addRow($row) + public function addRow(TableSeparator|array $row): static { if ($row instanceof TableSeparator) { $this->rowsCopy[] = $row; @@ -57,10 +56,6 @@ public function addRow($row) return parent::addRow($row); } - if (!is_array($row)) { - throw new \InvalidArgumentException('A row must be an array or a TableSeparator instance.'); - } - $this->rowsCopy[] = array_values($row); return parent::addRow($row); @@ -70,12 +65,14 @@ public function addRow($row) * {@inheritdoc} * * Overrides Table->setHeaders() so the header content can be accessed. + * + * @param array $headers */ - public function setHeaders(array $headers) + public function setHeaders(array $headers): static { $headers = array_values($headers); - if (!empty($headers) && !is_array($headers[0])) { - $headers = array($headers); + if ($headers && !is_array($headers[0])) { + $headers = [$headers]; } $this->headersCopy = $headers; @@ -88,7 +85,7 @@ public function setHeaders(array $headers) * * Overrides Table->render(), to adapt all the cells to the table width. */ - public function render() + public function render(): void { $this->adaptRows(); parent::render(); @@ -97,7 +94,7 @@ public function render() /** * Adapt rows based on the terminal width. */ - protected function adaptRows() + protected function adaptRows(): void { // Go through all headers and rows, wrapping their cells until each // column meets the max column width. @@ -108,21 +105,21 @@ protected function adaptRows() /** * Modify table rows, wrapping their cells' content to the max column width. * - * @param array $rows - * @param array $maxColumnWidths + * @param array|TableSeparator> $rows + * @param array $maxColumnWidths * - * @return array + * @return array|TableSeparator> */ - protected function adaptCells(array $rows, array $maxColumnWidths) + protected function adaptCells(array $rows, array $maxColumnWidths): array { foreach ($rows as &$row) { if ($row instanceof TableSeparator) { continue; } foreach ($row as $column => &$cell) { - $contents = $cell instanceof TableCell ? $cell->__toString() : $cell; + $contents = (string) $cell; // Replace Windows line endings, because Symfony's buildTableRows() does not respect them. - if (\strpos($contents, "\r\n") !== false) { + if (str_contains($contents, "\r\n")) { $contents = \str_replace("\r\n", "\n", $contents); if ($cell instanceof AdaptiveTableCell) { $cell = $cell->withValue($contents); @@ -150,19 +147,14 @@ protected function adaptCells(array $rows, array $maxColumnWidths) } /** - * Word-wrap the contents of a cell, so that they fit inside a max width. - * - * @param string $contents - * @param int $width - * - * @return string + * Word-wraps the contents of a cell, so that they fit inside a max width. */ - protected function wrapCell($contents, $width) + private function wrapCell(string $contents, int $width): string { // Account for left-indented cells. - if (strpos($contents, ' ') === 0) { + if (str_starts_with($contents, ' ')) { $trimmed = ltrim($contents, ' '); - $indentAmount = Helper::strlen($contents) - Helper::strlen($trimmed); + $indentAmount = Helper::width($contents) - Helper::width($trimmed); $indent = str_repeat(' ', $indentAmount); return preg_replace('/^/m', $indent, $this->wrapWithDecoration($trimmed, $width - $indentAmount)); @@ -173,13 +165,8 @@ protected function wrapCell($contents, $width) /** * Word-wraps the contents of a cell, accounting for decoration. - * - * @param string $formattedText - * @param int $maxLength - * - * @return string */ - public function wrapWithDecoration($formattedText, $maxLength) + public function wrapWithDecoration(string $formattedText, int $maxLength): string { $plainText = Helper::removeDecoration($this->outputCopy->getFormatter(), $formattedText); if ($plainText === $formattedText) { @@ -194,7 +181,7 @@ public function wrapWithDecoration($formattedText, $maxLength) $tagChunks = []; $lastTagClose = 0; foreach ($matches[0] as $match) { - list($tagChunk, $tagOffset) = $match; + [$tagChunk, $tagOffset] = $match; if (substr($formattedText, $tagOffset - 1, 1) === '\\') { continue; } @@ -251,23 +238,21 @@ public function wrapWithDecoration($formattedText, $maxLength) $lineOffset += $lineLength; } - $wrapped = implode("\n", $lines) . implode($remainingTagChunks); + $wrapped = implode("\n", $lines) . implode('', $remainingTagChunks); // Ensure that tags are closed at the end of each line and re-opened at // the beginning of the next one. - $wrapped = preg_replace_callback('@(<' . $tagRegex . '>)(((?!(?\n" . $matches[1], $matches[2]); - }, $wrapped); + $wrapped = preg_replace_callback('@(<' . $tagRegex . '>)(((?!(? $matches[1] . str_replace("\n", "\n" . $matches[1], $matches[2]), $wrapped); return $wrapped; } /** - * @return array + * @return array * An array of the maximum column widths that fit into the table width, - * keyed by the column number. + * indexed by the column's key in the table's rows (a name or number). */ - protected function getMaxColumnWidths() + protected function getMaxColumnWidths(): array { // Loop through the table rows and headers, building multidimensional // arrays of the 'original' and 'minimum' column widths. In the same @@ -281,7 +266,7 @@ protected function getMaxColumnWidths() } $columnCount = 0; foreach ($row as $column => $cell) { - $columnCount += $column instanceof TableCell ? $column->getColspan() - 1 : 1; + $columnCount += $cell instanceof TableCell ? $cell->getColspan() - 1 : 1; // The column width is the width of the widest cell. $cellWidth = $this->getCellWidth($cell); @@ -328,7 +313,7 @@ protected function getMaxColumnWidths() $maxColumnWidth = $minColumnWidths[$column]; } - $maxColumnWidths[$column] = $maxColumnWidth; + $maxColumnWidths[$column] = (int) $maxColumnWidth; $totalWidth -= $columnWidth; $maxContentWidth -= $maxColumnWidth; } @@ -342,17 +327,17 @@ protected function getMaxColumnWidths() * @param int $columnCount * The number of columns in the table. * - * @return int + * @return int|float * The maximum table width, minus the width taken up by decoration. */ - protected function getMaxContentWidth($columnCount) + protected function getMaxContentWidth(int $columnCount): int|float { $style = $this->getStyle(); $verticalBorderQuantity = $columnCount + 1; $paddingQuantity = $columnCount * 2; return $this->maxTableWidth - - $verticalBorderQuantity * strlen($style->getVerticalBorderChar()) + - $verticalBorderQuantity * strlen((string) $style->getBorderChars()[3]) - $paddingQuantity * strlen($style->getPaddingChar()); } @@ -362,15 +347,16 @@ protected function getMaxContentWidth($columnCount) * This is inspired by Table->getCellWidth(), but this also accounts for * multi-line cells. * - * @param string|TableCell $cell + * @param mixed $cell * * @return float|int */ - private function getCellWidth($cell) + private function getCellWidth(mixed $cell): int|float { $lineWidths = [0]; + $formatter = $this->outputCopy->getFormatter(); foreach (explode(PHP_EOL, (string) $cell) as $line) { - $lineWidths[] = Helper::strlenWithoutDecoration($this->outputCopy->getFormatter(), $line); + $lineWidths[] = Helper::width(Helper::removeDecoration($formatter, $line)); } $cellWidth = max($lineWidths); if ($cell instanceof TableCell && $cell->getColspan() > 1) { diff --git a/src/Console/AdaptiveTableCell.php b/src/Console/AdaptiveTableCell.php index 0539f1159a..ba03f5c316 100644 --- a/src/Console/AdaptiveTableCell.php +++ b/src/Console/AdaptiveTableCell.php @@ -1,49 +1,48 @@ {$flag} = (bool) $options[$flag]; - unset($options[$flag]); - } + if (isset($options['wrap'])) { + $this->wrap = (bool) $options['wrap']; + unset($options['wrap']); } parent::__construct($value, $options); } - /** - * @return bool - */ - public function canWrap() + public function canWrap(): bool { return $this->wrap; } /** - * Create a new cell object based on this, with a new value. - * - * @param string $value - * - * @return self + * Creates a new cell object based on this, with a new value. */ - public function withValue($value) + public function withValue(string $value): self { $options = [ 'colspan' => $this->getColspan(), 'rowspan' => $this->getRowspan(), 'wrap' => $this->canWrap(), + 'style' => $this->getStyle(), ]; return new self($value, $options); diff --git a/src/Console/Animation.php b/src/Console/Animation.php index 7d29ef3aa9..aa57caff29 100644 --- a/src/Console/Animation.php +++ b/src/Console/Animation.php @@ -1,20 +1,19 @@ output = $output; - $this->interval = $interval; - $this->frames = \array_map(function ($frame) use ($interval) { - return \is_string($frame) ? new AnimationFrame($frame, $interval) : $frame; - }, $frames); + $this->frames = \array_map(fn($frame) => \is_string($frame) ? new AnimationFrame($frame, $this->interval) : $frame, $frames); } /** @@ -50,7 +45,7 @@ public function __construct(OutputInterface $output, array $frames, $interval = * * @param string $placeholder */ - public function render($placeholder = '.') + public function render(string $placeholder = '.'): void { // Ensure that at least $this->interval microseconds have passed since // the last frame. diff --git a/src/Console/AnimationFrame.php b/src/Console/AnimationFrame.php index 5919feeb5e..e3b04b41ae 100644 --- a/src/Console/AnimationFrame.php +++ b/src/Console/AnimationFrame.php @@ -1,24 +1,19 @@ content = $content; - $this->duration = $duration; - } + public function __construct(private string $content, private int $duration = 50000) {} - public function getDuration() + public function getDuration(): int { return $this->duration; } - public function __toString() + public function __toString(): string { return $this->content; } diff --git a/src/Console/ArrayArgument.php b/src/Console/ArrayArgument.php index 2931b0e20f..308a633da5 100644 --- a/src/Console/ArrayArgument.php +++ b/src/Console/ArrayArgument.php @@ -1,20 +1,24 @@ getArgument($argName); if (!\is_array($value)) { @@ -26,12 +30,12 @@ public static function getArgument(\Symfony\Component\Console\Input\InputInterfa /** * Gets the value of an array input option. * - * @param \Symfony\Component\Console\Input\InputInterface $input + * @param InputInterface $input * @param string $optionName * * @return string[] */ - public static function getOption(\Symfony\Component\Console\Input\InputInterface $input, $optionName) + public static function getOption(InputInterface $input, string $optionName): array { $value = $input->getOption($optionName); if (!\is_array($value)) { @@ -45,14 +49,18 @@ public static function getOption(\Symfony\Component\Console\Input\InputInterface * * @param string[] $args * - * @return array + * @return string[] */ - public static function split($args) + public static function split(array $args): array { $split = []; foreach ($args as $arg) { - $split = \array_merge($split, \preg_split('/[,\s]+/', $arg)); + $splitArg = \preg_split('/[,\s]+/', $arg); + if (!$splitArg) { + throw new \RuntimeException('Failed to split argument by commas/whitespace'); + } + $split = \array_merge($split, $splitArg); } - return \array_filter($split, '\\strlen'); + return \array_filter($split, fn(string $a): bool => \strlen($a) > 0); } } diff --git a/src/Console/Bot.php b/src/Console/Bot.php index b6c73900fa..19d86ce9d3 100644 --- a/src/Console/Bot.php +++ b/src/Console/Bot.php @@ -1,5 +1,7 @@ preg_replace('/^/m', $indent, (string) file_get_contents($filename)) + . $signature, $filenames); parent::__construct($output, $frames); } diff --git a/src/Console/CompleterInterface.php b/src/Console/CompleterInterface.php new file mode 100644 index 0000000000..078b7fb342 --- /dev/null +++ b/src/Console/CompleterInterface.php @@ -0,0 +1,16 @@ +writeData($this->getInputArgumentData($argument), $options); } @@ -24,7 +26,7 @@ protected function describeInputArgument(InputArgument $argument, array $options /** * {@inheritdoc} */ - protected function describeInputOption(InputOption $option, array $options = []) + protected function describeInputOption(InputOption $option, array $options = []): void { $this->writeData($this->getInputOptionData($option), $options); } @@ -32,7 +34,7 @@ protected function describeInputOption(InputOption $option, array $options = []) /** * {@inheritdoc} */ - protected function describeInputDefinition(InputDefinition $definition, array $options = []) + protected function describeInputDefinition(InputDefinition $definition, array $options = []): void { $this->writeData($this->getInputDefinitionData($definition), $options); } @@ -40,7 +42,7 @@ protected function describeInputDefinition(InputDefinition $definition, array $o /** * {@inheritdoc} */ - protected function describeCommand(Command $command, array $options = []) + protected function describeCommand(Command $command, array $options = []): void { $this->writeData($this->getCommandData($command), $options); } @@ -48,15 +50,12 @@ protected function describeCommand(Command $command, array $options = []) /** * {@inheritdoc} */ - protected function describeApplication(Application $application, array $options = []) + protected function describeApplication(Application $application, array $options = []): void { - $describedNamespace = isset($options['namespace']) ? $options['namespace'] : null; - $description = new ApplicationDescription($application, $describedNamespace, !empty($options['all'])); - $commands = []; + $describedNamespace = $options['namespace'] ?? null; + $description = (new DescriptorUtils())->describeNamespaces($application, $describedNamespace, !empty($options['all'])); - foreach ($description->getCommands() as $command) { - $commands[] = $this->getCommandData($command); - } + $commands = array_map($this->getCommandData(...), $description['commands']); $data = []; if ('UNKNOWN' !== $application->getName()) { @@ -72,9 +71,7 @@ protected function describeApplication(Application $application, array $options $data['namespace'] = $describedNamespace; } else { // Only show namespaces with at least one (non-hidden) command. - $data['namespaces'] = array_values(array_filter($description->getNamespaces(), function ($n) { - return !empty($n['commands']); - })); + $data['namespaces'] = array_values(array_filter($description['namespaces'], fn($n): bool => !empty($n['commands']))); } $this->writeData($data, $options); @@ -83,18 +80,15 @@ protected function describeApplication(Application $application, array $options /** * Writes data as json. */ - private function writeData(array $data, array $options) + private function writeData(array $data, array $options): void { - $flags = isset($options['json_encoding']) ? $options['json_encoding'] : 0; + $flags = $options['json_encoding'] ?? 0; $flags |= JSON_UNESCAPED_SLASHES; $this->write(json_encode($data, $flags)); } - /** - * @return array - */ - private function getInputArgumentData(InputArgument $argument) + private function getInputArgumentData(InputArgument $argument): array { return [ 'name' => $argument->getName(), @@ -105,14 +99,11 @@ private function getInputArgumentData(InputArgument $argument) ]; } - /** - * @return array - */ - private function getInputOptionData(InputOption $option) + private function getInputOptionData(InputOption $option): array { return [ - 'name' => '--'.$option->getName(), - 'shortcut' => $option->getShortcut() ? '-'.str_replace('|', '|-', $option->getShortcut()) : '', + 'name' => '--' . $option->getName(), + 'shortcut' => $option->getShortcut() ? '-' . str_replace('|', '|-', $option->getShortcut()) : '', 'accept_value' => $option->acceptValue(), 'is_value_required' => $option->isValueRequired(), 'is_multiple' => $option->isArray(), @@ -122,15 +113,9 @@ private function getInputOptionData(InputOption $option) ]; } - /** - * @return array - */ - private function getInputDefinitionData(InputDefinition $definition) + private function getInputDefinitionData(InputDefinition $definition): array { - $inputArguments = []; - foreach ($definition->getArguments() as $name => $argument) { - $inputArguments[$name] = $this->getInputArgumentData($argument); - } + $inputArguments = array_map($this->getInputArgumentData(...), $definition->getArguments()); $inputOptions = []; foreach ($definition->getOptions() as $name => $option) { @@ -146,11 +131,12 @@ private function getInputDefinitionData(InputDefinition $definition) ]; } - /** - * @return array - */ - private function getCommandData(Command $command) + private function getCommandData(Command $command): array { + if ($command instanceof LazyCommand) { + $command = $command->getCommand(); + } + $command->getSynopsis(); $command->mergeApplicationDefinition(false); $aliases = $command instanceof CommandBase ? $command->getVisibleAliases() : $command->getAliases(); diff --git a/src/Console/CustomMarkdownDescriptor.php b/src/Console/CustomMarkdownDescriptor.php index 85235cfe12..30955a754f 100644 --- a/src/Console/CustomMarkdownDescriptor.php +++ b/src/Console/CustomMarkdownDescriptor.php @@ -1,9 +1,13 @@ cliExecutableName = $cliExecutableName ?: basename($_SERVER['PHP_SELF']); - } + public function __construct(private readonly string $cliExecutableName) {} /** * @inheritDoc */ - protected function describeApplication(Application $application, array $options = array()) + protected function describeApplication(Application $application, array $options = []): void { - $describedNamespace = isset($options['namespace']) ? $options['namespace'] : null; - $description = new ApplicationDescription($application, $describedNamespace, !empty($options['all'])); + $describedNamespace = $options['namespace'] ?? null; + $description = (new DescriptorUtils())->describeNamespaces($application, $describedNamespace, !empty($options['all'])); $title = sprintf('%s %s', $application->getName(), $application->getVersion()); + $this->write($title . "\n" . str_repeat('=', Helper::width($title))); - $this->write($title."\n".str_repeat('=', Helper::strlen($title))); - - $commands = []; - foreach ($description->getNamespaces() as $namespace) { - foreach ($namespace['commands'] as $key => $name) { - $command = $description->getCommand($name); - - // Ensure the command is only shown under its canonical name. - if ($name !== $command->getName() || $command->isHidden()) { - unset($namespace['commands'][$key]); - continue; - } - $commands[$name] = $command; - } + foreach ($description['namespaces'] as $namespace) { if (empty($namespace['commands'])) { continue; } if (ApplicationDescription::GLOBAL_NAMESPACE !== $namespace['id']) { $this->write("\n\n"); - $this->write('**'.$namespace['id'].':**'); + $this->write('**' . $namespace['id'] . ':**'); } $this->write("\n\n"); - $this->write(implode("\n", array_map(function ($commandName) use ($description) { - return sprintf('* [`%s`](#%s)', $commandName, str_replace(':', '', $description->getCommand($commandName)->getName())); + $this->write(implode("\n", array_map(function (Command $command): string { + return sprintf('* [`%s`](#%s)', $command->getName(), str_replace(':', '', $command->getName())); }, $namespace['commands']))); } - foreach ($commands as $command) { + foreach ($description['commands'] as $command) { $this->write("\n\n"); $this->describeCommand($command); } @@ -69,13 +52,16 @@ protected function describeApplication(Application $application, array $options /** * @inheritdoc */ - protected function describeCommand(Command $command, array $options = []) + protected function describeCommand(Command $command, array $options = []): void { + if ($command instanceof LazyCommand) { + $command = $command->getCommand(); + } $command->getSynopsis(); $command->mergeApplicationDefinition(false); $this->write($command->getName() . "\n" - . str_repeat('-', strlen($command->getName()))."\n"); + . str_repeat('-', strlen((string) $command->getName())) . "\n"); if ($description = $command->getDescription()) { $this->write("$description\n\n"); @@ -84,7 +70,7 @@ protected function describeCommand(Command $command, array $options = []) $aliases = $command instanceof CommandBase ? $command->getVisibleAliases() : $command->getAliases(); if ($aliases) { $this->write( - 'Aliases: ' . '`'.implode('`, `', $aliases).'`' . "\n\n" + 'Aliases: ' . '`' . implode('`, `', $aliases) . '`' . "\n\n", ); } @@ -95,7 +81,7 @@ protected function describeCommand(Command $command, array $options = []) $this->write("\n\n"); } - $this->describeInputDefinition($command->getNativeDefinition()); + $this->describeInputDefinition($command->getDefinition()); $this->write("\n\n"); if ($command instanceof CommandBase && ($examples = $command->getExamples())) { @@ -108,7 +94,7 @@ protected function describeCommand(Command $command, array $options = []) $example['description'], $this->cliExecutableName, $name, - $example['commandline'] + $example['commandline'], )); } $this->write("\n"); @@ -118,7 +104,7 @@ protected function describeCommand(Command $command, array $options = []) /** * {@inheritdoc} */ - protected function describeInputArgument(InputArgument $argument, array $options = []) + protected function describeInputArgument(InputArgument $argument, array $options = []): void { $this->write('* `' . $argument->getName() . '`'); $notes = [ @@ -138,11 +124,11 @@ protected function describeInputArgument(InputArgument $argument, array $options /** * {@inheritdoc} */ - protected function describeInputOption(InputOption $option, array $options = []) + protected function describeInputOption(InputOption $option, array $options = []): void { $this->write('* `--' . $option->getName() . '`'); if ($shortcut = $option->getShortcut()) { - $this->write(" (`-" . implode('|-', explode('|', $shortcut)). "`)"); + $this->write(" (`-" . implode('|-', explode('|', $shortcut)) . "`)"); } $notes = []; if ($option->isArray()) { diff --git a/src/Console/CustomTextDescriptor.php b/src/Console/CustomTextDescriptor.php index 37d87dc4c0..cde00b1af9 100644 --- a/src/Console/CustomTextDescriptor.php +++ b/src/Console/CustomTextDescriptor.php @@ -1,4 +1,7 @@ cliExecutableName = $cliExecutableName ?: basename($_SERVER['PHP_SELF']); - } + public function __construct(private readonly string $cliExecutableName) {} /** * @inheritdoc */ - protected function describeCommand(Command $command, array $options = []) + protected function describeCommand(Command $command, array $options = []): void { + if ($command instanceof LazyCommand) { + $command = $command->getCommand(); + } + $command->getSynopsis(); $command->mergeApplicationDefinition(false); @@ -56,11 +55,10 @@ protected function describeCommand(Command $command, array $options = []) $this->writeText(' ' . $command->getSynopsis(), $options); $this->writeText("\n"); + $this->writeText("\n"); - $definition = clone $command->getNativeDefinition(); - $definition->setOptions(array_filter($definition->getOptions(), function (InputOption $opt) { - return !$opt instanceof HiddenInputOption; - })); + $definition = clone $command->getDefinition(); + $definition->setOptions(array_filter($definition->getOptions(), fn(InputOption $opt): bool => !$opt instanceof HiddenInputOption)); $this->describeInputDefinition($definition, $options); $this->writeText("\n"); @@ -82,7 +80,7 @@ protected function describeCommand(Command $command, array $options = []) $example['description'], $this->cliExecutableName, $name, - $example['commandline'] + $example['commandline'], )); } } @@ -91,20 +89,19 @@ protected function describeCommand(Command $command, array $options = []) /** * @inheritdoc */ - protected function describeApplication(ConsoleApplication $application, array $options = []) + protected function describeApplication(ConsoleApplication $application, array $options = []): void { - $describedNamespace = isset($options['namespace']) ? $options['namespace'] : null; - $description = new ApplicationDescription($application, $describedNamespace, !empty($options['all'])); + $describedNamespace = $options['namespace'] ?? null; + $description = (new DescriptorUtils())->describeNamespaces($application, $describedNamespace, !empty($options['all'])); + $commands = $description['commands']; + $width = $this->getColumnWidth($commands); if (isset($options['raw_text']) && $options['raw_text']) { - $width = $this->getColumnWidth($description->getCommands()); - - foreach ($description->getCommands() as $command) { + foreach ($commands as $command) { $this->writeText(sprintf("%-{$width}s %s", $command->getName(), $command->getDescription()), $options); $this->writeText("\n"); } } else { - $width = $this->getColumnWidth($description->getCommands()); $this->writeText($application->getHelp(), $options); $this->writeText("\n\n"); @@ -112,28 +109,16 @@ protected function describeApplication(ConsoleApplication $application, array $o if ($describedNamespace) { $this->writeText( sprintf("Available commands for the \"%s\" namespace:", $application->findNamespace($describedNamespace)), - $options + $options, ); } else { $this->writeText('Available commands:', $options); } // Display commands grouped by namespace. - foreach ($description->getNamespaces() as $namespace) { - // Filter hidden commands in the namespace. - /** @var Command[] $commands */ - $commands = []; - foreach ($namespace['commands'] as $name) { - $command = $description->getCommand($name); - - // Ensure the command is only shown under its canonical name. - if ($name === $command->getName()) { - $commands[$name] = $command; - } - } - + foreach ($description['namespaces'] as $namespace) { // Skip the namespace if it doesn't contain any commands. - if (!count($commands)) { + if (!count($namespace['commands'])) { continue; } @@ -144,7 +129,7 @@ protected function describeApplication(ConsoleApplication $application, array $o } // Display each command. - foreach ($commands as $name => $command) { + foreach ($namespace['commands'] as $command) { $aliases = $command instanceof CommandBase ? $command->getVisibleAliases() : $command->getAliases(); @@ -153,10 +138,10 @@ protected function describeApplication(ConsoleApplication $application, array $o $this->writeText( sprintf( " %-{$width}s %s", - "$name" . $this->formatAliases($aliases), - $command->getDescription() + '' . $command->getName() . '' . $this->formatAliases($aliases), + $command->getDescription(), ), - $options + $options, ); } } @@ -165,14 +150,11 @@ protected function describeApplication(ConsoleApplication $application, array $o } } - /** - * {@inheritdoc} - */ - protected function writeText($content, array $options = []) + protected function writeText(string $content, array $options = []): void { $this->write( isset($options['raw_text']) && $options['raw_text'] ? strip_tags($content) : $content, - isset($options['raw_output']) ? !$options['raw_output'] : true + !isset($options['raw_output']) || !$options['raw_output'], ); } @@ -181,7 +163,7 @@ protected function writeText($content, array $options = []) * * @return string */ - protected function formatAliases(array $aliases) + protected function formatAliases(array $aliases): string { return $aliases ? " (" . implode(', ', $aliases) . ")" : ''; } @@ -193,27 +175,18 @@ protected function formatAliases(array $aliases) * * @return string */ - protected function formatDefaultValue($default) + protected function formatDefaultValue(mixed $default): string { - if (PHP_VERSION_ID < 50400) { - return str_replace('\/', '/', json_encode($default)); - } - - return json_encode($default, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + return json_encode($default, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); } - /** - * @param Command[] $commands - * - * @return int - */ - protected function getColumnWidth(array $commands) + protected function getColumnWidth(array $commands): int|float { $width = 0; foreach ($commands as $command) { $aliasesString = $this->formatAliases($command->getAliases()); - $commandWidth = strlen($command->getName()) + strlen($aliasesString); - $width = $commandWidth > $width ? $commandWidth : $width; + $commandWidth = strlen((string) $command->getName()) + strlen($aliasesString); + $width = max($commandWidth, $width); } // Limit to a maximum. @@ -239,7 +212,7 @@ protected function getColumnWidth(array $commands) /** * {@inheritdoc} */ - protected function describeInputOption(InputOption $option, array $options = array()) + protected function describeInputOption(InputOption $option, array $options = []): void { if ($option->acceptValue() && null !== $option->getDefault() && (!is_array($option->getDefault()) || count($option->getDefault()))) { $default = sprintf(' [default: %s]', $this->formatDefaultValue($option->getDefault())); @@ -249,20 +222,21 @@ protected function describeInputOption(InputOption $option, array $options = arr $value = ''; if ($option->acceptValue()) { - $value = '='.strtoupper($option->getName()); + $value = '=' . strtoupper($option->getName()); if ($option->isValueOptional()) { - $value = '['.$value.']'; + $value = '[' . $value . ']'; } } - $totalWidth = isset($options['total_width']) ? $options['total_width'] : $this->calculateTotalWidthForOptions(array($option)); - $synopsis = sprintf('%s%s', + $totalWidth = $options['total_width'] ?? $this->calculateTotalWidthForOptions([$option]); + $synopsis = sprintf( + '%s%s', $option->getShortcut() ? sprintf('-%s, ', $option->getShortcut()) : ' ', - sprintf('--%s%s', $option->getName(), $value) + sprintf('--%s%s', $option->getName(), $value), ); - $spacingWidth = $totalWidth - Helper::strlen($synopsis); + $spacingWidth = $totalWidth - Helper::width($synopsis); // Ensure the description is indented and word-wrapped to fit the // terminal width. @@ -272,12 +246,13 @@ protected function describeInputOption(InputOption $option, array $options = arr if ($option->isArray()) { $description .= ' (multiple values allowed)'; } - $description = preg_replace('/\s*[\r\n]\s*/', "\n".str_repeat(' ', $totalWidth + 4), wordwrap($description, $descriptionWidth)); + $description = preg_replace('/\s*[\r\n]\s*/', "\n" . str_repeat(' ', $totalWidth + 4), wordwrap($description, $descriptionWidth)); - $this->writeText(sprintf(' %s %s%s', + $this->writeText(sprintf( + ' %s %s%s', $synopsis, str_repeat(' ', $spacingWidth), - $description + $description, ), $options); } @@ -286,15 +261,15 @@ protected function describeInputOption(InputOption $option, array $options = arr * * @return int */ - private function calculateTotalWidthForOptions(array $options) + private function calculateTotalWidthForOptions(array $options): int { $totalWidth = 0; foreach ($options as $option) { // "-" + shortcut + ", --" + name - $nameLength = 1 + max(Helper::strlen($option->getShortcut()), 1) + 4 + Helper::strlen($option->getName()); + $nameLength = 1 + max(Helper::width($option->getShortcut()), 1) + 4 + Helper::width($option->getName()); if ($option->acceptValue()) { - $valueLength = 1 + Helper::strlen($option->getName()); // = + value + $valueLength = 1 + Helper::width($option->getName()); // = + value $valueLength += $option->isValueOptional() ? 2 : 0; // [ + ] $nameLength += $valueLength; diff --git a/src/Console/DescriptorUtils.php b/src/Console/DescriptorUtils.php new file mode 100644 index 0000000000..f513359b5b --- /dev/null +++ b/src/Console/DescriptorUtils.php @@ -0,0 +1,47 @@ +}>, 'commands': array} + */ + public function describeNamespaces(Application $application, ?string $namespace = null, bool $showHidden = false): array + { + $description = new ApplicationDescription($application, $namespace, $showHidden); + $commands = []; + $namespaces = []; + foreach ($description->getNamespaces() as $id => $namespace) { + $namespaces[$id] = ['id' => $id, 'commands' => []]; + foreach ($namespace['commands'] as $name) { + $command = $description->getCommand($name); + if ($command instanceof LazyCommand) { + $command = $command->getCommand(); + } + + // Ensure the command is only included under its canonical name. + if ($name !== $command->getName()) { + continue; + } + + if (($showHidden || !$command->isHidden()) && $command->isEnabled()) { + $namespaces[$id]['commands'][$name] = $command; + $commands[$name] = $command; + } + } + } + return ['namespaces' => $namespaces, 'commands' => $commands]; + } +} diff --git a/src/Console/EventSubscriber.php b/src/Console/EventSubscriber.php index fdf71aa480..5135edf126 100644 --- a/src/Console/EventSubscriber.php +++ b/src/Console/EventSubscriber.php @@ -1,38 +1,33 @@ config = $config; - } + public function __construct(private CacheProvider $cache, private Config $config) {} /** * {@inheritdoc} */ public static function getSubscribedEvents() { - return [ConsoleEvents::ERROR => 'onException']; + return [ConsoleEvents::ERROR => 'onError']; } /** @@ -40,28 +35,27 @@ public static function getSubscribedEvents() * * @param ConsoleErrorEvent $event */ - public function onException(ConsoleErrorEvent $event) + public function onError(ConsoleErrorEvent $event): void { $error = $event->getError(); // Replace Guzzle connect exceptions with a friendlier message. This // also prevents the user from seeing two exceptions (one direct from // Guzzle, one from RingPHP). - if ($error instanceof ConnectException && strpos($error->getMessage(), 'cURL error 6') !== false) { + if ($error instanceof ConnectException && str_contains($error->getMessage(), 'cURL error 6')) { $request = $error->getRequest(); $event->setError(new ConnectionFailedException( - "Failed to connect to host: " . $request->getHost() + "Failed to connect to host: " . $request->getUri()->getHost() . " \nPlease check your Internet connection.", - $error + $error, )); $event->stopPropagation(); } // Handle Guzzle exceptions, i.e. HTTP 4xx or 5xx errors. - if (($error instanceof ClientException || $error instanceof ServerException) - && ($response = $error->getResponse())) { + if ($error instanceof ClientException || $error instanceof ServerException) { + $response = $error->getResponse(); $request = $error->getRequest(); - $requestConfig = $request->getConfig(); $json = (array) json_decode($response->getBody()->__toString(), true); // Create a friendlier message for the OAuth2 "Invalid refresh token" @@ -72,17 +66,17 @@ public function onException(ConsoleErrorEvent $event) $event->setError(new LoginRequiredException( 'Invalid refresh token.', $this->config, - $error + $error, )); $event->stopPropagation(); - } elseif ($response->getStatusCode() === 401 && $requestConfig['auth'] === 'oauth2') { + } elseif ($response->getStatusCode() === 401) { $event->setError(new LoginRequiredException( 'Unauthorized.', $this->config, - $error + $error, )); $event->stopPropagation(); - } elseif ($response->getStatusCode() === 403 && $requestConfig['auth'] === 'oauth2') { + } elseif ($response->getStatusCode() === 403) { $event->setError(new PermissionDeniedException($this->permissionDeniedMessage($request), $error)); $event->stopPropagation(); } @@ -91,7 +85,7 @@ public function onException(ConsoleErrorEvent $event) // When an environment is found to be in the wrong state, perhaps our // cache is old - we should invalidate it. if ($error instanceof EnvironmentStateException) { - (new Api())->clearEnvironmentsCache($error->getEnvironment()->project); + $this->cache->delete('environments:' . $error->getEnvironment()->project); } } @@ -101,18 +95,18 @@ public function onException(ConsoleErrorEvent $event) * @param RequestInterface $request * @return string */ - private function permissionDeniedMessage(RequestInterface $request) + private function permissionDeniedMessage(RequestInterface $request): string { $pathsPermissionTypes = [ '/projects' => 'project', '/subscriptions' => 'project', '/environments' => 'environment', - '/organizations' => 'organization' + '/organizations' => 'organization', ]; - $requestUrl = $request->getUrl(); + $requestPath = $request->getUri()->getPath(); $permissionTypes = []; foreach ($pathsPermissionTypes as $path => $pathsPermissionType) { - if (strpos($requestUrl, $path) !== false) { + if (str_contains($requestPath, $path)) { $permissionTypes[$pathsPermissionType] = $pathsPermissionType; } } diff --git a/src/Console/HiddenInputOption.php b/src/Console/HiddenInputOption.php index 27e87f0ba9..2352d157c8 100644 --- a/src/Console/HiddenInputOption.php +++ b/src/Console/HiddenInputOption.php @@ -1,5 +1,7 @@ 0) { // This is the parent process. If the child process succeeds, this // receives SIGCHLD. If it fails, this receives SIGTERM. - declare (ticks = 1); - pcntl_signal(SIGCHLD, function () { + declare (ticks=1); + pcntl_signal(SIGCHLD, function (): void { exit; }); - pcntl_signal(SIGTERM, function () { + pcntl_signal(SIGTERM, function (): void { exit(1); }); @@ -93,7 +95,7 @@ public static function fork() * @param bool $error * Whether the parent process should exit with an error status. */ - public static function killParent($error = false) + public static function killParent(bool $error = false): void { if (!posix_kill(posix_getppid(), $error ? SIGTERM : SIGCHLD)) { throw new \RuntimeException('Failed to kill parent process'); @@ -116,13 +118,13 @@ public static function killParent($error = false) * @return int * The process PID. */ - public function startProcess(Process $process, $pidFile, OutputInterface $log) + public function startProcess(Process $process, string $pidFile, OutputInterface $log): int { $this->processes[$pidFile] = $process; $errLog = $log instanceof ConsoleOutputInterface ? $log->getErrorOutput() : $log; try { - $process->start(function ($type, $buffer) use ($log, $errLog) { + $process->start(function ($type, $buffer) use ($log, $errLog): void { $output = $type === Process::ERR ? $errLog : $log; $output->write($buffer); }); @@ -147,7 +149,7 @@ public function startProcess(Process $process, $pidFile, OutputInterface $log) * @param OutputInterface $log * A log file as a Symfony Console output object. */ - public function monitor(OutputInterface $log) + public function monitor(OutputInterface $log): void { while (count($this->processes)) { sleep(1); @@ -168,13 +170,13 @@ public function monitor(OutputInterface $log) $log->writeln(sprintf( 'Process failed with exit code %s: %s', $exitCode, - $process->getCommandLine() + $process->getCommandLine(), )); } else { $log->writeln(sprintf( 'Process stopped with exit code %s: %s', $exitCode, - $process->getCommandLine() + $process->getCommandLine(), )); } unlink($pidFile); @@ -184,12 +186,7 @@ public function monitor(OutputInterface $log) } } - /** - * @param int $exitCode - * - * @return string|false - */ - private function getSignal($exitCode) + private function getSignal(int $exitCode): false|string { if ($exitCode < 128 || $exitCode > 162) { return false; diff --git a/src/Console/ProgressMessage.php b/src/Console/ProgressMessage.php index f38d4dc1e8..addb6a726e 100644 --- a/src/Console/ProgressMessage.php +++ b/src/Console/ProgressMessage.php @@ -1,5 +1,7 @@ output->isDecorated()) { $this->show($message); @@ -38,10 +38,8 @@ public function showIfOutputDecorated($message) /** * Shows a progress message. - * - * @param string $message */ - public function show($message) + public function show(string $message): void { if ($message === '' || $message === $this->message) { return; @@ -55,11 +53,11 @@ public function show($message) /** * Hides the progress message, if one is visible. Mark it as done if hiding is not supported. */ - public function done() + public function done(): void { if ($this->visible) { if ($this->output->isDecorated() && !$this->output->isVeryVerbose()) { - $this->overwrite('', \substr_count($this->message, "\n")); + $this->overwrite('', \substr_count((string) $this->message, "\n")); } $this->visible = false; } @@ -73,7 +71,7 @@ public function done() * @param string $message * @param int $lineCount */ - private function overwrite($message, $lineCount = 0) + private function overwrite(string $message, int $lineCount = 0): void { // Erase $lineCount previous lines. if ($lineCount > 0) { diff --git a/src/Console/Winky.php b/src/Console/Winky.php index 669dd92053..ed08cec8b6 100644 --- a/src/Console/Winky.php +++ b/src/Console/Winky.php @@ -1,5 +1,7 @@ isDecorated()) { foreach ($sources as &$source) { - $source = preg_replace_callback('/([\x{2588}\x{2591} ])\1*/u', function (array $matches) { + $source = preg_replace_callback('/([\x{2588}\x{2591} ])\1*/u', function (array $matches): string { $styles = [ ' ' => "\033[47m", '█' => "\033[40m", '░' => "\033[48;5;217m", ]; - $char = mb_substr($matches[0], 0, 1); + $char = mb_substr((string) $matches[0], 0, 1); - return $styles[$char] . str_repeat(' ', mb_strlen($matches[0])) . "\033[0m"; + return $styles[$char] . str_repeat(' ', mb_strlen((string) $matches[0])) . "\033[0m"; }, $source); } } @@ -44,9 +46,7 @@ public function __construct(OutputInterface $output, $signature = '') $signatureIndent = str_repeat(' ', intval(strlen($indent) + floor($width / 2) - floor(strlen($signature) / 2))); $signature = "\n" . $signatureIndent . $signature; } - $sources = array_map(function ($source) use ($indent, $signature) { - return "\n" . preg_replace('/^/m', $indent, $source) . $signature . "\n"; - }, $sources); + $sources = array_map(fn($source): string => "\n" . preg_replace('/^/m', $indent, (string) $source) . $signature . "\n", $sources); $frames = []; $frames[] = new AnimationFrame($sources['normal'], 1200000); diff --git a/src/CredentialHelper/KeyringUnavailableException.php b/src/CredentialHelper/KeyringUnavailableException.php index daeefd735b..e68aacd57b 100644 --- a/src/CredentialHelper/KeyringUnavailableException.php +++ b/src/CredentialHelper/KeyringUnavailableException.php @@ -1,5 +1,7 @@ getProcess(); - if ($process->getExitCode() === 2 && strpos($process->getErrorOutput(), 'libsecret-CRITICAL') !== false) { + if ($process->getExitCode() === 2 && str_contains($process->getErrorOutput(), 'libsecret-CRITICAL')) { $message .= "\n" . sprintf('This can happen when the password dialog is dismissed. Is the login %s unlocked?', $type); } else { $message .= "\n" . $e->getMessage(); diff --git a/src/CredentialHelper/Manager.php b/src/CredentialHelper/Manager.php index dd3782aa37..98a4a7fa5c 100644 --- a/src/CredentialHelper/Manager.php +++ b/src/CredentialHelper/Manager.php @@ -1,5 +1,7 @@ config = $config; $this->shell = $shell ?: new Shell(); } @@ -26,8 +26,9 @@ public function __construct(Config $config, Shell $shell = null) * * @return bool */ - public function isSupported() { - if ($this->config->getWithDefault('api.disable_credential_helpers', false)) { + public function isSupported(): bool + { + if ($this->config->getBool('api.disable_credential_helpers')) { return false; } try { @@ -43,14 +44,15 @@ public function isSupported() { * * @return bool */ - public function isInstalled() { - if (self::$isInstalled) { - return true; + public function isInstalled(): bool + { + if (isset(self::$isInstalled)) { + return self::$isInstalled; } try { $helper = $this->getHelper(); - } catch (\RuntimeException $e) { + } catch (\RuntimeException) { return self::$isInstalled = false; } @@ -62,7 +64,8 @@ public function isInstalled() { /** * Installs the credential helper. */ - public function install() { + public function install(): void + { if ($this->isInstalled()) { return; } @@ -74,11 +77,9 @@ public function install() { /** * Stores a secret. - * - * @param string $serverUrl - * @param string $secret */ - public function store($serverUrl, $secret) { + public function store(string $serverUrl, string $secret): void + { $this->exec('store', json_encode([ 'ServerURL' => $serverUrl, 'Username' => (string) (OsUtil::isWindows() ? getenv('USERNAME') : getenv('USER')), @@ -88,10 +89,9 @@ public function store($serverUrl, $secret) { /** * Erases a secret. - * - * @param string $serverUrl */ - public function erase($serverUrl) { + public function erase(string $serverUrl): void + { try { $this->exec('erase', $serverUrl); } catch (ProcessFailedException $e) { @@ -105,12 +105,9 @@ public function erase($serverUrl) { /** * Loads a secret. - * - * @param string $serverUrl - * - * @return string|false */ - public function get($serverUrl) { + public function get(string $serverUrl): string|false + { try { $data = $this->exec('get', $serverUrl); } catch (ProcessFailedException $e) { @@ -120,24 +117,21 @@ public function get($serverUrl) { throw $e; } - if (is_string($data)) { - $json = json_decode($data, true); - if ($json === null || !isset($json['Secret'])) { - throw new \RuntimeException('Failed to decode JSON from credential helper'); - } - - return $json['Secret']; + $json = json_decode($data, true); + if ($json === null || !isset($json['Secret'])) { + throw new \RuntimeException('Failed to decode JSON from credential helper'); } - return false; + return $json['Secret']; } /** * Lists all secrets. * - * @return array + * @return array */ - public function listAll() { + public function listAll(): array + { $data = $this->exec('list'); return (array) json_decode($data, true); @@ -146,12 +140,10 @@ public function listAll() { /** * Verifies that a helper exists and is executable at a path. * - * @param array $helper - * @param string $path - * - * @return bool + * @param array{url: string, filename: string, sha256: string} $helper */ - private function helperExists(array $helper, $path) { + private function helperExists(array $helper, string $path): bool + { if (!file_exists($path) || !is_executable($path)) { return false; } @@ -162,10 +154,11 @@ private function helperExists(array $helper, $path) { /** * Downloads, extracts, and moves the credential helper to a destination. * - * @param array $helper + * @param array{url: string, filename: string, sha256: string} $helper * @param string $destination */ - private function download(array $helper, $destination) { + private function download(array $helper, string $destination): void + { if ($this->helperExists($helper, $destination)) { return; } @@ -184,8 +177,8 @@ private function download(array $helper, $destination) { $tmpFile = $destination . '-tmp'; try { // Write the file, either directly or via extracting its archive. - if (preg_match('/(\.tar\.gz|\.tgz|\.zip)$/', $helper['url']) === 1) { - $this->extractBinFromArchive($contents, substr($helper['url'], -4) === '.zip', $helper['filename'], $tmpFile); + if (preg_match('/(\.tar\.gz|\.tgz|\.zip)$/', (string) $helper['url']) === 1) { + $this->extractBinFromArchive($contents, str_ends_with((string) $helper['url'], '.zip'), $helper['filename'], $tmpFile); } else { $fs->dumpFile($tmpFile, $contents); } @@ -197,7 +190,7 @@ private function download(array $helper, $destination) { } // Make the file executable and move it into place. - $fs->chmod($tmpFile, 0700); + $fs->chmod($tmpFile, 0o700); $fs->rename($tmpFile, $destination, true); } finally { $fs->remove($tmpFile); @@ -212,7 +205,7 @@ private function download(array $helper, $destination) { * @param string $internalFilename * @param string $destination */ - private function extractBinFromArchive($archiveContents, $zip, $internalFilename, $destination) + private function extractBinFromArchive(string $archiveContents, bool $zip, string $internalFilename, string $destination): void { $fs = new Filesystem(); $tmpDir = $tmpFile = $fs->tempnam(sys_get_temp_dir(), 'cli-helpers'); @@ -231,13 +224,13 @@ private function extractBinFromArchive($archiveContents, $zip, $internalFilename } } elseif ($this->shell->commandExists('unzip')) { $command = 'unzip ' . escapeshellarg($tmpFile) . ' -d ' . escapeshellarg($tmpDir); - $this->shell->execute($command, null, true); + $this->shell->mustExecute($command); } else { throw new \RuntimeException('Failed to extract zip: unzip is not installed'); } } else { $command = 'tar -xzp -f ' . escapeshellarg($tmpFile) . ' -C ' . escapeshellarg($tmpDir); - $this->shell->execute($command, null, true); + $this->shell->mustExecute($command); } if (!file_exists($tmpDir . DIRECTORY_SEPARATOR . $internalFilename)) { throw new \RuntimeException('File not found: ' . $tmpDir . DIRECTORY_SEPARATOR . $internalFilename); @@ -249,9 +242,10 @@ private function extractBinFromArchive($archiveContents, $zip, $internalFilename } /** - * @return array + * @return array> */ - private function getHelpers() { + private function getHelpers(): array + { return [ 'windows' => [ 'amd64' => [ @@ -295,9 +289,10 @@ private function getHelpers() { /** * Finds a helper package for this system. * - * @return array + * @return array{url: string, filename: string, sha256: string} */ - private function getHelper() { + private function getHelper(): array + { $arch = php_uname('m'); if ($arch === 'ARM64') { $arch = 'arm64'; @@ -340,12 +335,12 @@ private function getHelper() { } // The Linux helper needs "libsecret" to be installed. - if (!$this->shell->execute('ldconfig --print-cache | grep -q libsecret')) { + if ($this->shell->execute('ldconfig --print-cache | grep -q libsecret') === false) { throw new \RuntimeException('Unable to find a credentials helper for this system (libsecret is not installed)'); } } - if (substr($helpers[$os][$arch]['url'], -4) === '.zip' && !class_exists('\\ZipArchive') && !$this->shell->commandExists('unzip')) { + if (str_ends_with($helpers[$os][$arch]['url'], '.zip') && !class_exists('\\ZipArchive') && !$this->shell->commandExists('unzip')) { throw new \RuntimeException('Unable to install a credentials helper for this system (it is a .zip file and the zip extension is unavailable)'); } @@ -354,16 +349,12 @@ private function getHelper() { /** * Executes a command on the credential helper. - * - * @param string $command - * @param mixed|null $input - * - * @return string|bool */ - private function exec($command, $input = null) { + private function exec(string $command, mixed $input = null): string + { $this->install(); - return $this->shell->execute([$this->getExecutablePath(), $command], null, true, true, [], 10, $input); + return $this->shell->mustExecute([$this->getExecutablePath(), $command], timeout: 10, input: $input); } /** @@ -371,7 +362,8 @@ private function exec($command, $input = null) { * * @return string */ - private function getExecutablePath() { + private function getExecutablePath(): string + { $path = $this->config->getWritableUserDir() . DIRECTORY_SEPARATOR . 'credential-helper'; if (OsUtil::isWindows()) { $path .= '.exe'; diff --git a/src/CredentialHelper/SessionStorage.php b/src/CredentialHelper/SessionStorage.php index 468b598e7e..5385a56095 100644 --- a/src/CredentialHelper/SessionStorage.php +++ b/src/CredentialHelper/SessionStorage.php @@ -1,17 +1,17 @@ manager = $manager; $this->serverUrlBase = rtrim($serverUrlPrefix, '/'); } /** - * @param SessionInterface $session + * @param string $sessionId * * @return string */ - private function serverUrl(SessionInterface $session) { + private function serverUrl(string $sessionId): string + { // Remove the 'cli-' prefix from the session ID; - $sessionId = $session->getId(); - if (strpos($sessionId, 'cli-') === 0) { + if (str_starts_with($sessionId, 'cli-')) { $sessionId = substr($sessionId, 4); } @@ -47,17 +46,15 @@ private function serverUrl(SessionInterface $session) { /** * {@inheritdoc} */ - public function load(SessionInterface $session) + public function load(string $sessionId): array { try { - $secret = $this->manager->get($this->serverUrl($session)); + $secret = $this->manager->get($this->serverUrl($sessionId)); } catch (\RuntimeException $e) { throw new \RuntimeException('Failed to load the session', 0, $e); } - if ($secret !== false) { - $session->setData($this->deserialize($secret)); - } + return $secret ? $this->deserialize($secret) : []; } /** @@ -65,14 +62,15 @@ public function load(SessionInterface $session) * * @return bool */ - public function hasAnySessions() { + public function hasAnySessions(): bool + { return count($this->listAllServerUrls()) > 0; } /** - * @return array + * @return string[] */ - public function listSessionIds() + public function listSessionIds(): array { $ids = []; foreach ($this->listAllServerUrls() as $url) { @@ -87,7 +85,8 @@ public function listSessionIds() /** * Deletes all sessions from the credential store. */ - public function deleteAll() { + public function deleteAll(): void + { foreach ($this->listAllServerUrls() as $url) { $this->manager->erase($url); } @@ -96,21 +95,19 @@ public function deleteAll() { /** * @return string[] */ - private function listAllServerUrls() { + private function listAllServerUrls(): array + { $list = $this->manager->listAll(); - return array_filter(array_keys($list), function ($url) { - return strpos($url, $this->serverUrlBase . '/') === 0; - }); + return array_filter(array_keys($list), fn($url): bool => str_starts_with((string) $url, $this->serverUrlBase . '/')); } /** * {@inheritdoc} */ - public function save(SessionInterface $session) + public function save(string $sessionId, array $data): void { - $serverUrl = $this->serverUrl($session); - $data = $session->getData(); + $serverUrl = $this->serverUrl($sessionId); if (empty($data)) { try { $this->manager->erase($serverUrl); @@ -120,7 +117,7 @@ public function save(SessionInterface $session) return; } try { - $this->manager->store($this->serverUrl($session), $this->serialize($data)); + $this->manager->store($serverUrl, $this->serialize($data)); } catch (\RuntimeException $e) { throw new \RuntimeException('Failed to store the session', 0, $e); } @@ -129,13 +126,13 @@ public function save(SessionInterface $session) /** * Serialize session data. * - * @param array $data + * @param array $data * * @return string */ - private function serialize(array $data) + private function serialize(array $data): string { - return base64_encode(json_encode($data, JSON_UNESCAPED_SLASHES)); + return base64_encode((string) json_encode($data, JSON_UNESCAPED_SLASHES)); } /** @@ -143,11 +140,11 @@ private function serialize(array $data) * * @param string $data * - * @return array + * @return array */ - private function deserialize($data) + private function deserialize(string $data): array { - $result = json_decode(base64_decode($data, true), true); + $result = json_decode((string) base64_decode($data, true), true); return is_array($result) ? $result : []; } diff --git a/src/Event/EnvironmentsChangedEvent.php b/src/Event/EnvironmentsChangedEvent.php index f835701399..6f311d8863 100644 --- a/src/Event/EnvironmentsChangedEvent.php +++ b/src/Event/EnvironmentsChangedEvent.php @@ -1,31 +1,25 @@ project = $project; - $this->environments = $environments; - } + /** @param Environment[] $environments */ + public function __construct(private readonly Project $project, private readonly array $environments) {} - public function getProject() + public function getProject(): Project { return $this->project; } - public function getEnvironments() + /** @return Environment[] */ + public function getEnvironments(): array { return $this->environments; } diff --git a/src/Event/LoginRequiredEvent.php b/src/Event/LoginRequiredEvent.php index 22f82ca7a3..63d1ee7357 100644 --- a/src/Event/LoginRequiredEvent.php +++ b/src/Event/LoginRequiredEvent.php @@ -1,30 +1,19 @@ authMethods = $authMethods; - $this->maxAge = $maxAge; - $this->hasApiToken = $hasApiToken; - } + /** @param string[] $authMethods */ + public function __construct(private readonly array $authMethods = [], private readonly ?int $maxAge = null, private readonly bool $hasApiToken = false) {} - public function getMessage() + public function getMessage(): string { $message = 'Authentication is required.'; if (count($this->authMethods) > 0 || $this->maxAge !== null) { @@ -33,7 +22,7 @@ public function getMessage() if (count($this->authMethods) === 1) { if ($this->authMethods[0] === 'mfa') { $message = 'Multi-factor authentication (MFA) is required.'; - } elseif (strpos($this->authMethods[0], 'sso:') === 0) { + } elseif (str_starts_with($this->authMethods[0], 'sso:')) { $message = 'Single sign-on (SSO) is required.'; } } elseif ($this->maxAge !== null) { @@ -42,7 +31,7 @@ public function getMessage() return $message; } - public function getExtendedMessage(Config $config) + public function getExtendedMessage(Config $config): string { $message = $this->getMessage(); if ($this->hasApiToken) { @@ -51,7 +40,7 @@ public function getExtendedMessage(Config $config) } return $message; } - $executable = $config->get('application.executable'); + $executable = $config->getStr('application.executable'); $cmd = 'login'; if ($options = $this->getLoginOptionsCmdLine()) { $cmd .= ' ' . $options; @@ -63,7 +52,8 @@ public function getExtendedMessage(Config $config) /** * @return array */ - public function getLoginOptions() { + public function getLoginOptions(): array + { $loginOptions = []; if (count($this->authMethods) > 0) { $loginOptions['--method'] = $this->authMethods; @@ -77,7 +67,8 @@ public function getLoginOptions() { /** * @return string */ - public function getLoginOptionsCmdLine() { + public function getLoginOptionsCmdLine(): string + { $args = []; foreach ($this->getLoginOptions() as $option => $value) { $args[] = $option; @@ -86,10 +77,7 @@ public function getLoginOptionsCmdLine() { return implode(' ', $args); } - /** - * @return bool - */ - public function hasApiToken() + public function hasApiToken(): bool { return $this->hasApiToken; } diff --git a/src/Exception/ApiFeatureMissingException.php b/src/Exception/ApiFeatureMissingException.php index cd334f72cb..4513615dcb 100644 --- a/src/Exception/ApiFeatureMissingException.php +++ b/src/Exception/ApiFeatureMissingException.php @@ -1,5 +1,7 @@ message; $this->config = $config ?: new Config(); - $executable = $this->config->get('application.executable'); - $envPrefix = $this->config->get('application.env_prefix'); + $executable = $this->config->getStr('application.executable'); + $envPrefix = $this->config->getStr('application.env_prefix'); $message .= "\n\nPlease log in by running:\n $executable login" . "\n\nAlternatively, to log in using an API token (without a browser), run: $executable auth:api-token-login" . "\n\nTo authenticate non-interactively, configure an API token using the {$envPrefix}TOKEN environment variable."; @@ -27,7 +29,7 @@ public function __construct( parent::__construct($message, $previous); } - public function setMessageFromEvent(LoginRequiredEvent $event) + public function setMessageFromEvent(LoginRequiredEvent $event): void { $this->message = $event->getExtendedMessage($this->config); } diff --git a/src/Exception/NoOrganizationsException.php b/src/Exception/NoOrganizationsException.php index a767b419a6..222ed70386 100644 --- a/src/Exception/NoOrganizationsException.php +++ b/src/Exception/NoOrganizationsException.php @@ -1,25 +1,17 @@ totalNumOrgs = $totalNumOrgs; parent::__construct($message); } - /** - * @return int - */ - public function getTotalNumOrgs() + public function getTotalNumOrgs(): int { return $this->totalNumOrgs; } diff --git a/src/Exception/PermissionDeniedException.php b/src/Exception/PermissionDeniedException.php index c698db8ee2..1215fdb1e6 100644 --- a/src/Exception/PermissionDeniedException.php +++ b/src/Exception/PermissionDeniedException.php @@ -1,5 +1,7 @@ get('service.project_config_dir')) && $config->isCommandEnabled('project:set-remote')) { - $executable = $config->get('application.executable'); + if (is_dir($config->getStr('service.project_config_dir')) && $config->isCommandEnabled('project:set-remote')) { + $executable = $config->getStr('application.executable'); $message .= "\n\nTo set the project for this Git repository, run:\n $executable set-remote [id]"; } } diff --git a/src/GuzzleDebugMiddleware.php b/src/GuzzleDebugMiddleware.php new file mode 100644 index 0000000000..2a0fc4fb01 --- /dev/null +++ b/src/GuzzleDebugMiddleware.php @@ -0,0 +1,91 @@ +stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; + } + + public function __invoke(callable $next): callable + { + return function (RequestInterface $request, array $options) use ($next): PromiseInterface { + if (!$this->stdErr->isVeryVerbose()) { + return $next($request, $options); + } + $started = microtime(true); + $seq = self::$requestSeq++; + + $this->stdErr->writeln(sprintf( + '> Making HTTP request #%d: %s', + $seq, + $this->formatMessage($request, '> '), + )); + + /** @var PromiseInterface $promise */ + $promise = $next($request, $options); + + return $promise->then(function (ResponseInterface $response) use ($seq, $started): ResponseInterface|PromiseInterface { + $this->stdErr->writeln(sprintf( + '\< Received response for #%d after %d ms: %s', + $seq, + (microtime(true) - $started) * 1000, + $this->formatMessage($response, '< '), + )); + return $response; + }); + }; + } + + private function formatMessage(RequestInterface|ResponseInterface $message, string $headerPrefix = ''): string + { + $startLine = $message instanceof RequestInterface + ? $this->getRequestFirstLine($message) + : $this->getResponseFirstLine($message); + if (!$this->includeHeaders) { + return $startLine; + } + $headers = ''; + foreach ($message->getHeaders() as $name => $values) { + if ($name === 'Authorization') { + $headers .= "\r\n{$headerPrefix}{$name}: [redacted]"; + } else { + $headers .= "\r\n{$headerPrefix}{$name}: " . implode(', ', $values); + } + } + return $startLine . $headers; + } + + private function getRequestFirstLine(RequestInterface $request): string + { + $method = $request->getMethod(); + $uri = $request->getUri(); + $protocolVersion = $request->getProtocolVersion(); + + return sprintf('%s %s HTTP/%s', $method, $uri, $protocolVersion); + } + + private function getResponseFirstLine(ResponseInterface $response): string + { + $statusCode = $response->getStatusCode(); + $reasonPhrase = $response->getReasonPhrase(); + $protocolVersion = $response->getProtocolVersion(); + + return sprintf('HTTP/%s %d %s', $protocolVersion, $statusCode, $reasonPhrase); + } + +} diff --git a/src/GuzzleDebugSubscriber.php b/src/GuzzleDebugSubscriber.php deleted file mode 100644 index 5392a4dc2e..0000000000 --- a/src/GuzzleDebugSubscriber.php +++ /dev/null @@ -1,94 +0,0 @@ -includeHeaders = $includeHeaders; - $this->stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; - } - - public function getEvents() - { - return [ - // Errors are not handled: they are already printed via exceptions. - 'before' => ['onBefore', RequestEvents::LATE], - 'complete' => ['onComplete', RequestEvents::LATE], - ]; - } - - public function onBefore(BeforeEvent $event) - { - if ($this->stdErr->isVeryVerbose()) { - $req = $event->getRequest(); - if (!$req->getConfig()->hasKey('started_at')) { - $req->getConfig()->set('started_at', microtime(true)); - } - if ($req->getConfig()->hasKey('seq')) { - $seq = $req->getConfig()->get('seq'); - } else { - if (self::$requestSeq === null) { - self::$requestSeq = 1; - } - $seq = self::$requestSeq++; - $req->getConfig()->set('seq', $seq); - } - $this->stdErr->writeln(sprintf( - '> Making HTTP request #%d: %s', - $seq, $this->formatMessage($req, '> ') - )); - } - } - - private function formatMessage(MessageInterface $message, $headerPrefix = '') - { - $startLine = AbstractMessage::getStartLine($message); - if (!$this->includeHeaders) { - return $startLine; - } - $headers = ''; - foreach ($message->getHeaders() as $name => $values) { - if ($name === 'Authorization') { - $headers .= "\r\n{$headerPrefix}{$name}: [redacted]"; - } else { - $headers .= "\r\n{$headerPrefix}{$name}: " . implode(', ', $values); - } - } - return $startLine . $headers; - } - - public function onComplete(CompleteEvent $event) - { - if (!$this->stdErr->isVeryVerbose() || !$event->hasResponse()) { - return; - } - $seq = $event->getRequest()->getConfig()->get('seq'); - if (($startedAt = $event->getRequest()->getConfig()->get('started_at')) !== null) { - $this->stdErr->writeln(sprintf( - '\< Received response for #%d after %d ms: %s', - $seq, (microtime(true) - $startedAt) * 1000, $this->formatMessage($event->getResponse(), '< ') - )); - } else { - $this->stdErr->writeln(sprintf( - '\< Received response for #%d: %s', - $seq, $this->formatMessage($event->getResponse(), '< ') - )); - } - } -} diff --git a/src/Local/ApplicationFinder.php b/src/Local/ApplicationFinder.php index 44134220e6..891effaa68 100644 --- a/src/Local/ApplicationFinder.php +++ b/src/Local/ApplicationFinder.php @@ -1,5 +1,7 @@ config = $config ?: new Config(); } @@ -28,21 +30,26 @@ public function __construct(Config $config = null) * * @return LocalApplication[] */ - public function findApplications($directory) + public function findApplications(string $directory): array { $applications = []; + $finder = $this->findAppConfigFiles($directory); + if (!$finder) { + return []; + } + // Find applications defined in individual files, e.g. // .platform.app.yaml. - foreach ($this->findAppConfigFiles($directory) as $file) { + foreach ($finder as $file) { $configFile = $file->getRealPath(); $appConfig = (array) (new YamlParser())->parseFile($configFile); $configuredRoot = $this->getExplicitRoot($appConfig, $directory); if ($configuredRoot !== null && !\is_dir($configuredRoot)) { throw new InvalidConfigException('Directory not found: ' . $configuredRoot, $configFile, 'source.root'); } - $appRoot = $configuredRoot !== null ? $configuredRoot : \dirname($configFile); - $appName = isset($appConfig['name']) ? $appConfig['name'] : null; + $appRoot = $configuredRoot !== null ? $configuredRoot : \dirname((string) $configFile); + $appName = $appConfig['name'] ?? null; if ($appName && isset($applications[$appConfig['name']])) { throw new InvalidConfigException(sprintf('An application named %s is already defined', $appConfig['name']), $configFile, 'name'); } @@ -74,13 +81,13 @@ public function findApplications($directory) /** * Finds applications via the grouped config file, e.g. .platform/applications.yaml. * - * @param $directory + * @param string $directory * - * @return array + * @return array */ - private function findGroupedApplications($directory) + private function findGroupedApplications(string $directory): array { - $configFile = $directory . DIRECTORY_SEPARATOR . $this->config->get('service.applications_config_file'); + $configFile = $directory . DIRECTORY_SEPARATOR . $this->config->getStr('service.applications_config_file'); if (!\file_exists($configFile)) { return []; } @@ -100,7 +107,7 @@ private function findGroupedApplications($directory) throw new InvalidConfigException('Directory not found: ' . $appRoot, $configFile, $key . '.source.root'); } $appRoot = \realpath($appRoot) ?: $appRoot; - $appName = isset($appConfig['name']) ? $appConfig['name'] : null; + $appName = $appConfig['name'] ?? null; if ($appName && isset($applications[$appName])) { throw new InvalidConfigException(sprintf('An application named %s is already defined', $appConfig['name']), $configFile, $key . '.name'); } @@ -115,43 +122,39 @@ private function findGroupedApplications($directory) * * @see https://docs.platform.sh/configuration/app/multi-app.html#explicit-sourceroot * - * @param array $appConfig + * @param array $appConfig * @param string $sourceDir * * @return string|null */ - private function getExplicitRoot(array $appConfig, $sourceDir) + private function getExplicitRoot(array $appConfig, string $sourceDir): ?string { if (!isset($appConfig['source']['root'])) { return null; } - return \rtrim($sourceDir . DIRECTORY_SEPARATOR . \ltrim($appConfig['source']['root'], '\\/'), DIRECTORY_SEPARATOR); + return \rtrim($sourceDir . DIRECTORY_SEPARATOR . \ltrim((string) $appConfig['source']['root'], '\\/'), DIRECTORY_SEPARATOR); } /** * Finds application config files using Symfony Finder. - * - * @param string $directory - * - * @return Finder|array */ - private function findAppConfigFiles($directory) + private function findAppConfigFiles(string $directory): ?Finder { // Finder can be extremely slow with a deep directory structure. The // search depth is limited to safeguard against this. $finder = new Finder(); if (!$this->config->has('service.app_config_file')) { - return []; + return null; } return $finder->in($directory) - ->name($this->config->get('service.app_config_file')) + ->name($this->config->getStr('service.app_config_file')) ->ignoreDotFiles(false) ->ignoreUnreadableDirs() ->ignoreVCS(true) ->exclude([ '.idea', - $this->config->get('local.local_dir'), + $this->config->getStr('local.local_dir'), 'builds', 'node_modules', 'vendor', diff --git a/src/Local/BuildFlavor/BuildFlavorBase.php b/src/Local/BuildFlavor/BuildFlavorBase.php index 11e2f1104d..50c7c7786b 100644 --- a/src/Local/BuildFlavor/BuildFlavorBase.php +++ b/src/Local/BuildFlavor/BuildFlavorBase.php @@ -1,5 +1,7 @@ * An array of filenames in the app root, mapped to destinations. The * destinations are filenames supporting the replacements: * "{webroot}" - see getWebRoot() (usually /app/public on Platform.sh) * "{approot}" - the $buildDir (usually /app on Platform.sh) */ - protected $specialDestinations = []; - - /** @var LocalApplication */ - protected $app; - - /** @var array */ - protected $settings = []; - - /** @var string */ - protected $buildDir; - - /** @var bool */ - protected $copy = false; - - /** @var OutputInterface */ - protected $output; + protected array $specialDestinations; - /** @var OutputInterface */ - protected $stdErr; + protected ?LocalApplication $app = null; + /** @var array $settings */ + protected array $settings = []; + protected string $buildDir = '.'; + protected bool $copy = false; - /** @var Filesystem */ - protected $fsHelper; + protected OutputInterface $output; + protected OutputInterface $stdErr; - /** @var Git */ - protected $gitHelper; + protected Filesystem $fsHelper; + protected Git $gitHelper; + protected Shell $shellHelper; - /** @var Shell */ - protected $shellHelper; + protected ?Config $config = null; - /** @var Config */ - protected $config; + protected ?string $appRoot = null; - /** @var string */ - protected $appRoot; - - /** @var string */ - private $documentRoot; + private ?string $documentRoot = null; /** * Whether all app files have just been symlinked or copied to the build. - * - * @var bool */ - private $buildInPlace = false; + private bool $buildInPlace = false; - /** - * @param object $fsHelper - * @param Shell|null $shellHelper - * @param object $gitHelper - */ - public function __construct($fsHelper = null, Shell $shellHelper = null, $gitHelper = null) + public function __construct(?Filesystem $fsHelper = null, ?Shell $shellHelper = null, ?Git $gitHelper = null) { $this->shellHelper = $shellHelper ?: new Shell(); $this->fsHelper = $fsHelper ?: new Filesystem($this->shellHelper); $this->gitHelper = $gitHelper ?: new Git($this->shellHelper); + $this->stdErr = $this->output = new NullOutput(); $this->specialDestinations = [ @@ -93,13 +71,13 @@ public function __construct($fsHelper = null, Shell $shellHelper = null, $gitHel ]; // Platform.sh has '.platform.app.yaml', but we need to be stricter. - $this->ignoredFiles = ['.*', ]; + $this->ignoredFiles = ['.*']; } /** * @inheritdoc */ - public function setOutput(OutputInterface $output) + public function setOutput(OutputInterface $output): void { $this->output = $output; $this->stdErr = $output instanceof ConsoleOutputInterface @@ -111,7 +89,7 @@ public function setOutput(OutputInterface $output) /** * @inheritdoc */ - public function addIgnoredFiles(array $ignoredFiles) + public function addIgnoredFiles(array $ignoredFiles): void { $this->ignoredFiles = array_merge($this->ignoredFiles, $ignoredFiles); } @@ -119,7 +97,7 @@ public function addIgnoredFiles(array $ignoredFiles) /** * @inheritdoc */ - public function prepare($buildDir, LocalApplication $app, Config $config, array $settings = []) + public function prepare(string $buildDir, LocalApplication $app, Config $config, array $settings = []): void { $this->app = $app; $this->appRoot = $app->getRoot(); @@ -127,10 +105,8 @@ public function prepare($buildDir, LocalApplication $app, Config $config, array $this->settings = $settings; $this->config = $config; - if ($this->config->getWithDefault('local.copy_on_windows', false)) { - $this->fsHelper->setCopyOnWindows(true); - } - $this->ignoredFiles[] = $this->config->getWithDefault('local.web_root', '_www'); + $this->fsHelper->setCopyOnWindows($this->config->getBool('local.copy_on_windows')); + $this->ignoredFiles[] = $this->config->getStr('local.web_root'); $this->setBuildDir($buildDir); @@ -145,7 +121,7 @@ public function prepare($buildDir, LocalApplication $app, Config $config, array /** * {@inheritdoc} */ - public function setBuildDir($buildDir) + public function setBuildDir(string $buildDir): void { $this->buildDir = $buildDir; } @@ -153,7 +129,7 @@ public function setBuildDir($buildDir) /** * Process the defined special destinations. */ - protected function processSpecialDestinations() + protected function processSpecialDestinations(): void { foreach ($this->specialDestinations as $sourcePattern => $relDestination) { $matched = glob($this->appRoot . '/' . $sourcePattern, GLOB_NOSORT); @@ -168,7 +144,7 @@ protected function processSpecialDestinations() $absDestination = str_replace( ['{webroot}', '{approot}'], [$this->getWebRoot(), $this->buildDir], - $relDestination + $relDestination, ); foreach ($matched as $source) { @@ -196,8 +172,8 @@ protected function processSpecialDestinations() $this->stdErr->writeln( sprintf( "Overriding existing path '%s' in destination", - str_replace($this->buildDir . '/', '', $destination) - ) + str_replace($this->buildDir . '/', '', $destination), + ), ); $this->fsHelper->remove($destination); } @@ -218,11 +194,11 @@ protected function processSpecialDestinations() * * @return string */ - protected function getSharedDir() + protected function getSharedDir(): string { - $shared = $this->app->getSourceDir() . '/' . $this->config->get('local.shared_dir'); + $shared = $this->app->getSourceDir() . '/' . $this->config->getStr('local.shared_dir'); if (!$this->app->isSingle()) { - $shared .= '/' . preg_replace('/[^a-z0-9\-_]+/i', '-', $this->app->getName()); + $shared .= '/' . preg_replace('/[^a-z0-9\-_]+/i', '-', (string) $this->app->getName()); } $this->fsHelper->mkdir($shared); @@ -232,7 +208,7 @@ protected function getSharedDir() /** * @inheritdoc */ - public function getWebRoot() + public function getWebRoot(): string { return $this->buildDir . '/' . $this->documentRoot; } @@ -240,7 +216,7 @@ public function getWebRoot() /** * @return string */ - public function getAppDir() + public function getAppDir(): string { return $this->buildDir; } @@ -251,7 +227,7 @@ public function getAppDir() * @return string * The absolute path to the build directory where files have been copied. */ - protected function copyToBuildDir() + protected function copyToBuildDir(): string { $this->buildInPlace = true; $buildDir = $this->buildDir; @@ -274,10 +250,10 @@ protected function copyToBuildDir() * * @param string $buildDir */ - private function cloneToBuildDir($buildDir) + private function cloneToBuildDir(string $buildDir): void { - $gitRoot = $this->gitHelper->getRoot($this->appRoot, true); - $ref = $this->gitHelper->execute(['rev-parse', 'HEAD'], $gitRoot, true); + $gitRoot = (string) $this->gitHelper->getRoot($this->appRoot, true); + $ref = (string) $this->gitHelper->execute(['rev-parse', 'HEAD'], $gitRoot, true); $cloneArgs = ['--recursive', '--shared']; $tmpRepo = $buildDir . '-repo'; @@ -298,7 +274,7 @@ private function cloneToBuildDir($buildDir) /** * @inheritdoc */ - public function install() + public function install(): void { $this->processSharedFileMounts(); } @@ -310,7 +286,7 @@ public function install() * shared files directory, and symlinks it into the appropriate path in the * build. */ - protected function processSharedFileMounts() + protected function processSharedFileMounts(): void { $sharedDir = $this->getSharedDir(); @@ -325,7 +301,7 @@ protected function processSharedFileMounts() return; } - $sharedDirRelative = $this->config->get('local.shared_dir'); + $sharedDirRelative = $this->config->getStr('local.shared_dir'); $this->stdErr->writeln('Creating symbolic links to mimic shared file mounts'); foreach ($sharedFileMounts as $appPath => $sharedPath) { $target = $sharedDir . '/' . $sharedPath; @@ -336,10 +312,10 @@ protected function processSharedFileMounts() $this->fsHelper->remove($link); } if (!file_exists($target)) { - $this->fsHelper->mkdir($target, 0775); + $this->fsHelper->mkdir($target, 0o775); } $this->stdErr->writeln( - ' Symlinking ' . $appPath . ' to ' . $targetRelative . '' + ' Symlinking ' . $appPath . ' to ' . $targetRelative . '', ); $this->fsHelper->symlink($target, $link); } @@ -350,20 +326,20 @@ protected function processSharedFileMounts() * * This helps with database setup, etc. */ - protected function installDrupalSettingsLocal() + protected function installDrupalSettingsLocal(): void { $sitesDefault = $this->getWebRoot() . '/sites/default'; $shared = $this->getSharedDir(); $settingsLocal = $sitesDefault . '/settings.local.php'; - if ($shared !== false && is_dir($sitesDefault) && !file_exists($settingsLocal)) { + if (is_dir($sitesDefault) && !file_exists($settingsLocal)) { $sharedSettingsLocal = $shared . '/settings.local.php'; - $relative = $this->config->get('local.shared_dir') . '/settings.local.php'; + $relative = $this->config->getStr('local.shared_dir') . '/settings.local.php'; if (!file_exists($sharedSettingsLocal)) { $this->stdErr->writeln("Creating file: $relative"); $this->fsHelper->copy(CLI_ROOT . '/resources/drupal/settings.local.php.dist', $sharedSettingsLocal); $this->stdErr->writeln( - 'Edit this file to add your database credentials and other Drupal configuration.' + 'Edit this file to add your database credentials and other Drupal configuration.', ); } else { $this->stdErr->writeln("Symlinking $relative into sites/default"); @@ -375,7 +351,7 @@ protected function installDrupalSettingsLocal() /** * @inheritdoc */ - public function getKeys() + public function getKeys(): array { return ['default']; } @@ -383,7 +359,7 @@ public function getKeys() /** * @inheritdoc */ - public function canArchive() + public function canArchive(): bool { return !$this->buildInPlace || $this->copy; } @@ -394,7 +370,7 @@ public function canArchive() * @param string $source The path to a default .gitignore file, relative to * the 'resources' directory. */ - protected function copyGitIgnore($source) + protected function copyGitIgnore(string $source): void { $source = CLI_ROOT . '/resources/' . $source; $gitRoot = $this->gitHelper->getRoot($this->appRoot); diff --git a/src/Local/BuildFlavor/BuildFlavorInterface.php b/src/Local/BuildFlavor/BuildFlavorInterface.php index 2cd90a27e1..9bea38bc49 100644 --- a/src/Local/BuildFlavor/BuildFlavorInterface.php +++ b/src/Local/BuildFlavor/BuildFlavorInterface.php @@ -1,5 +1,7 @@ $settings Additional settings for the build. * Possible settings include: * - clone (bool, default false) Clone the repository to the build * directory before building, where possible. @@ -49,52 +50,52 @@ public function setOutput(OutputInterface $output); * - no-cache (bool, default false) Disable the package cache (if * relevant and if the package manager supports this). */ - public function prepare($buildDir, LocalApplication $app, Config $config, array $settings = []); + public function prepare(string $buildDir, LocalApplication $app, Config $config, array $settings = []): void; /** * Set the build directory. * * @param string $buildDir */ - public function setBuildDir($buildDir); + public function setBuildDir(string $buildDir): void; /** * Build this application. Acquire dependencies, plugins, libraries, and * submodules. */ - public function build(); + public function build(): void; /** * Move files into place. This could happen straight after the build, or * after an old build archive has been extracted. */ - public function install(); + public function install(): void; /** * Get the document root after build. * * @return string */ - public function getWebRoot(); + public function getWebRoot(): string; /** * Get the application root after build. * * @return string */ - public function getAppDir(); + public function getAppDir(): string; /** * Find whether the build may be archived. * * @return bool */ - public function canArchive(); + public function canArchive(): bool; /** * Add to the list of files (in the app root) that should not be copied. * - * @param array $ignoredFiles + * @param string[] $ignoredFiles */ - public function addIgnoredFiles(array $ignoredFiles); + public function addIgnoredFiles(array $ignoredFiles): void; } diff --git a/src/Local/BuildFlavor/Composer.php b/src/Local/BuildFlavor/Composer.php index a208b168c5..00494c7eb2 100644 --- a/src/Local/BuildFlavor/Composer.php +++ b/src/Local/BuildFlavor/Composer.php @@ -1,22 +1,24 @@ copyToBuildDir(); @@ -46,7 +48,7 @@ public function build() $this->processSpecialDestinations(); } - public function install() + public function install(): void { parent::install(); $this->copyGitIgnore('gitignore-composer'); diff --git a/src/Local/BuildFlavor/Drupal.php b/src/Local/BuildFlavor/Drupal.php index 0f7407f563..1a33f48446 100644 --- a/src/Local/BuildFlavor/Drupal.php +++ b/src/Local/BuildFlavor/Drupal.php @@ -1,5 +1,7 @@ depth($depth) ->name('index.php'); foreach ($finder as $file) { - if (($f = fopen($file, 'r')) !== false) { - $beginning = fread($f, 3178); - fclose($f); - if ($beginning !== false && strpos($beginning, 'Drupal') !== false) { - return true; - } + try { + $o = $file->openFile(); + } catch (\RuntimeException) { + continue; + } + $beginning = $o->fread(3178); + if ($beginning !== false && str_contains($beginning, 'Drupal')) { + return true; } + unset($o); } // Check whether there is a composer.json file requiring Drupal core. @@ -71,7 +75,7 @@ public static function isDrupal($directory, $depth = '< 2') ->depth($depth) ->name('composer.json'); foreach ($finder as $file) { - $composerJson = json_decode(file_get_contents($file), true); + $composerJson = json_decode($file->getContents(), true); if (isset($composerJson['require']['drupal/core']) || isset($composerJson['require']['drupal/core-recommended']) || isset($composerJson['require']['drupal/drupal']) @@ -84,14 +88,14 @@ public static function isDrupal($directory, $depth = '< 2') return false; } - public function build() + public function build(): void { - $profiles = glob($this->appRoot . '/*.profile'); + $profiles = glob($this->appRoot . '/*.profile') ?: []; $projectMake = $this->findDrushMakeFile(); if (count($profiles) > 1) { throw new \Exception("Found multiple files ending in '*.profile' in the directory."); } elseif (count($profiles) == 1) { - $profileName = strtok(basename($profiles[0]), '.'); + [$profileName,] = explode('.', basename($profiles[0]), 2); $this->buildInProfileMode($profileName); } elseif ($projectMake) { $this->buildInProjectMode($projectMake); @@ -111,12 +115,9 @@ public function build() } /** - * Check that an application file is ignored in .gitignore. - * - * @param string $filename - * @param string $suggestion + * Checks that an application file is ignored in .gitignore. */ - protected function checkIgnored($filename, $suggestion = null) + protected function checkIgnored(string $filename, ?string $suggestion = null): void { if (!file_exists($filename)) { return; @@ -134,9 +135,9 @@ protected function checkIgnored($filename, $suggestion = null) /** * Set up options to pass to the drush commands. * - * @return array + * @return string[] */ - protected function getDrushFlags() + protected function getDrushFlags(): array { $drushFlags = [ '--yes', @@ -179,7 +180,7 @@ protected function getDrushFlags() * @return string|false * The absolute filename of the make file. */ - protected function findDrushMakeFile($required = false, $core = false) + protected function findDrushMakeFile(bool $required = false, bool $core = false): string|false { $candidates = [ 'project.make.yml', @@ -206,10 +207,11 @@ protected function findDrushMakeFile($required = false, $core = false) if ($required) { throw new \Exception( - ($core + ( + $core ? "Couldn't find a core make file in the directory." : "Couldn't find a make file in the directory." - ) . " Possible filenames: " . implode(',', $candidates) + ) . " Possible filenames: " . implode(',', $candidates), ); } @@ -219,7 +221,7 @@ protected function findDrushMakeFile($required = false, $core = false) /** * @return Drush */ - protected function getDrushHelper() + protected function getDrushHelper(): Drush { static $drushHelper; if (!isset($drushHelper)) { @@ -230,11 +232,9 @@ protected function getDrushHelper() } /** - * Build in 'project' mode, i.e. just using a Drush make file. - * - * @param string $projectMake + * Builds in 'project' mode, i.e. just using a Drush make file. */ - protected function buildInProjectMode($projectMake) + protected function buildInProjectMode(string $projectMake): void { $drushHelper = $this->getDrushHelper(); $drushHelper->ensureInstalled(); @@ -244,7 +244,7 @@ protected function buildInProjectMode($projectMake) $args = array_merge( ['make', $projectMake, $drupalRoot], - $drushFlags + $drushFlags, ); // Create a lock file automatically. @@ -279,7 +279,7 @@ protected function buildInProjectMode($projectMake) true, false, array_merge($this->ignoredFiles, array_keys($this->specialDestinations)), - $this->copy + $this->copy, ); } @@ -288,7 +288,7 @@ protected function buildInProjectMode($projectMake) * * @param string $profileName */ - protected function buildInProfileMode($profileName) + protected function buildInProfileMode(string $profileName): void { $drushHelper = $this->getDrushHelper(); $drushHelper->ensureInstalled(); @@ -315,7 +315,7 @@ protected function buildInProfileMode($profileName) $args = array_merge( ['make', '--no-core', '--contrib-destination=.', $projectMake, $tempProfileDir], - $drushFlags + $drushFlags, ); // Create a lock file automatically. @@ -329,7 +329,7 @@ protected function buildInProfileMode($profileName) if ($projectCoreMake) { $args = array_merge( ['make', $projectCoreMake, $drupalRoot], - $drushFlags + $drushFlags, ); // Create a lock file automatically. @@ -370,7 +370,7 @@ protected function buildInProfileMode($profileName) true, true, array_merge($this->ignoredFiles, array_keys($this->specialDestinations)), - $this->copy + $this->copy, ); } @@ -384,7 +384,7 @@ protected function buildInProfileMode($profileName) * * See https://github.com/platformsh/platformsh-cli/issues/175 */ - protected function processSettingsPhp() + protected function processSettingsPhp(): void { if ($this->copy) { // This behaviour only relates to symlinking. @@ -396,13 +396,13 @@ protected function processSettingsPhp() $this->fsHelper->copy($settingsPhpFile, $this->getWebRoot() . '/sites/default/settings.php'); $this->stdErr->writeln( " Your settings.php file has been copied (not symlinked) into the build directory." - . "\n You will need to rebuild if you edit this file." + . "\n You will need to rebuild if you edit this file.", ); $this->ignoredFiles[] = 'settings.php'; } } - public function install() + public function install(): void { $this->processSharedFileMounts(); @@ -421,11 +421,11 @@ public function install() // Symlink all files and folders from shared into sites/default. $shared = $this->getSharedDir(); - if ($shared !== false && is_dir($sitesDefault)) { + if (is_dir($sitesDefault)) { // Hidden files and files defined in "mounts" are skipped. $skip = ['.*']; foreach ($this->app->getSharedFileMounts() as $mount) { - list($skip[],) = explode('/', $mount, 2); + [$skip[], ] = explode('/', $mount, 2); } $this->fsHelper->symlinkAll($shared, $sitesDefault, true, false, $skip); diff --git a/src/Local/BuildFlavor/NoBuildFlavor.php b/src/Local/BuildFlavor/NoBuildFlavor.php index 30f02d193b..fc4144fe47 100644 --- a/src/Local/BuildFlavor/NoBuildFlavor.php +++ b/src/Local/BuildFlavor/NoBuildFlavor.php @@ -1,20 +1,22 @@ copyToBuildDir(); diff --git a/src/Local/BuildFlavor/NodeJs.php b/src/Local/BuildFlavor/NodeJs.php index 8563562463..292bce5571 100644 --- a/src/Local/BuildFlavor/NodeJs.php +++ b/src/Local/BuildFlavor/NodeJs.php @@ -1,5 +1,7 @@ copyToBuildDir(); @@ -41,7 +43,7 @@ public function build() $this->processSpecialDestinations(); } - public function install() + public function install(): void { parent::install(); $this->copyGitIgnore('gitignore-nodejs'); diff --git a/src/Local/BuildFlavor/Symfony.php b/src/Local/BuildFlavor/Symfony.php index 864a68ac52..ba8c034b80 100644 --- a/src/Local/BuildFlavor/Symfony.php +++ b/src/Local/BuildFlavor/Symfony.php @@ -1,16 +1,17 @@ copyGitIgnore('symfony/gitignore-standard'); diff --git a/src/Local/DependencyInstaller.php b/src/Local/DependencyInstaller.php index 932c517437..bd8a9fc24d 100644 --- a/src/Local/DependencyInstaller.php +++ b/src/Local/DependencyInstaller.php @@ -1,28 +1,29 @@ output = $output; - $this->shell = $shell; - } + public function __construct(protected OutputInterface $output, protected Shell $shell) {} /** * Modify the environment to make the installed dependencies available. * * @param string $destination - * @param array $dependencies + * @param array $dependencies */ - public function putEnv($destination, array $dependencies) + public function putEnv(string $destination, array $dependencies): void { $env = []; $paths = []; @@ -46,18 +47,18 @@ public function putEnv($destination, array $dependencies) } /** - * Install dependencies into a directory. + * Installs dependencies into a directory. * * @param string $destination - * @param array $dependencies - * @param bool $global - * - * @throws \Exception If a dependency fails to install. + * @param array $dependencies + * @param bool $global * * @return bool * False if a dependency manager is not available; otherwise true. + * + * @throws \Exception If a dependency fails to install. */ - public function installDependencies($destination, array $dependencies, $global = false) + public function installDependencies(string $destination, array $dependencies, bool $global = false): bool { $success = true; foreach ($dependencies as $stack => $stackDependencies) { @@ -66,13 +67,13 @@ public function installDependencies($destination, array $dependencies, $global = "Installing %s dependencies with '%s': %s", $stack, $manager->getCommandName(), - implode(', ', array_keys($stackDependencies)) + implode(', ', array_keys($stackDependencies)), )); if (!$manager->isAvailable()) { $this->output->writeln(sprintf( "Cannot install %s dependencies: '%s' is not installed.", $stack, - $manager->getCommandName() + $manager->getCommandName(), )); if ($manager->getInstallHelp()) { $this->output->writeln($manager->getInstallHelp()); @@ -88,12 +89,9 @@ public function installDependencies($destination, array $dependencies, $global = return $success; } - /** - * @param string $path - */ - protected function ensureDirectory($path) + protected function ensureDirectory(string $path): void { - if (!is_dir($path) && !mkdir($path, 0755, true)) { + if (!is_dir($path) && !mkdir($path, 0o755, true)) { throw new \RuntimeException('Failed to create directory: ' . $path); } } @@ -103,19 +101,19 @@ protected function ensureDirectory($path) * * @param string $name * - * @return \Platformsh\Cli\Local\DependencyManager\DependencyManagerInterface + * @return DependencyManagerInterface */ - protected function getManager($name) + protected function getManager(string $name): DependencyManagerInterface { // Python has 'python', 'python2', and 'python3'. - if (strpos($name, 'python') === 0) { - return new DependencyManager\Pip($this->shell, $name); + if (str_starts_with($name, 'python')) { + return new Pip($this->shell, $name); } $stacks = [ - 'nodejs' => new DependencyManager\Npm($this->shell), - 'ruby' => new DependencyManager\Bundler($this->shell), - 'php' => new DependencyManager\Composer($this->shell), + 'nodejs' => new Npm($this->shell), + 'ruby' => new Bundler($this->shell), + 'php' => new Composer($this->shell), ]; if (isset($stacks[$name])) { return $stacks[$name]; diff --git a/src/Local/DependencyManager/Bundler.php b/src/Local/DependencyManager/Bundler.php index a07698702b..2bd5804f34 100644 --- a/src/Local/DependencyManager/Bundler.php +++ b/src/Local/DependencyManager/Bundler.php @@ -1,15 +1,17 @@ formatGemfile($dependencies); @@ -44,11 +46,11 @@ public function install($path, array $dependencies, $global = false) } /** - * @param array $dependencies + * @param array $dependencies * * @return string */ - private function formatGemfile(array $dependencies) + private function formatGemfile(array $dependencies): string { $lines = ["source 'https://rubygems.org'"]; foreach ($dependencies as $package => $version) { diff --git a/src/Local/DependencyManager/Composer.php b/src/Local/DependencyManager/Composer.php index 55ee575123..3349531b93 100644 --- a/src/Local/DependencyManager/Composer.php +++ b/src/Local/DependencyManager/Composer.php @@ -1,15 +1,17 @@ installGlobal($dependencies); @@ -58,9 +60,9 @@ public function install($path, array $dependencies, $global = false) /** * Install dependencies globally. * - * @param array $dependencies + * @param array $dependencies */ - private function installGlobal(array $dependencies) + private function installGlobal(array $dependencies): void { $requirements = []; foreach ($dependencies as $package => $version) { @@ -69,7 +71,7 @@ private function installGlobal(array $dependencies) $this->runCommand( 'composer global require ' . '--no-progress --prefer-dist --optimize-autoloader --no-interaction ' - . implode(' ', array_map('escapeshellarg', $requirements)) + . implode(' ', array_map('escapeshellarg', $requirements)), ); } } diff --git a/src/Local/DependencyManager/DependencyManagerBase.php b/src/Local/DependencyManager/DependencyManagerBase.php index 99b475d20f..f4f92490c0 100644 --- a/src/Local/DependencyManager/DependencyManagerBase.php +++ b/src/Local/DependencyManager/DependencyManagerBase.php @@ -1,22 +1,21 @@ shell = $shell; - } + public function __construct(protected Shell $shell) {} /** * {@inheritdoc} */ - public function getCommandName() + public function getCommandName(): string { return $this->command; } @@ -24,7 +23,7 @@ public function getCommandName() /** * {@inheritdoc} */ - public function isAvailable() + public function isAvailable(): bool { return $this->shell->commandExists($this->getCommandName()); } @@ -32,23 +31,19 @@ public function isAvailable() /** * {@inheritdoc} */ - public function getEnvVars($path) + public function getEnvVars($path): array { return []; } - /** - * @param string $command - * @param string|null $path - */ - protected function runCommand($command, $path = null) + protected function runCommand(string $command, ?string $path = null): void { $code = $this->shell->executeSimple($command, $path); if ($code > 0) { throw new \RuntimeException(sprintf( 'The command failed with the exit code %d: %s', $code, - $command + $command, )); } } diff --git a/src/Local/DependencyManager/DependencyManagerInterface.php b/src/Local/DependencyManager/DependencyManagerInterface.php index dd18297bf1..b9ca06a57d 100644 --- a/src/Local/DependencyManager/DependencyManagerInterface.php +++ b/src/Local/DependencyManager/DependencyManagerInterface.php @@ -1,4 +1,7 @@ $dependencies An associative array of dependencies with * their versions. * @param bool $global Whether to install dependencies globally for * the user or system (i.e. not attached to the * project). */ - public function install($path, array $dependencies, $global = false); + public function install(string $path, array $dependencies, bool $global = false): void; /** * Returns a list of "bin" directories in which dependencies are installed. * - * @param string $path The path prefix for the dependencies. + * @param string $prefix The path prefix for the dependencies. * - * @return array An array of absolute paths. + * @return string[] An array of absolute paths. */ - public function getBinPaths($path); + public function getBinPaths(string $prefix): array; /** * Returns a list of environment variables for using installed dependencies. * * @param string $path The path prefix for the dependencies. * - * @return array An associative array of environment variables. + * @return array An associative array of environment variables. */ - public function getEnvVars($path); + public function getEnvVars(string $path): array; } diff --git a/src/Local/DependencyManager/Npm.php b/src/Local/DependencyManager/Npm.php index 26ea1993a0..5ad8fef87e 100644 --- a/src/Local/DependencyManager/Npm.php +++ b/src/Local/DependencyManager/Npm.php @@ -1,16 +1,19 @@ installGlobal($dependencies); @@ -49,9 +52,9 @@ public function install($path, array $dependencies, $global = false) /** * Install dependencies globally. * - * @param array $dependencies + * @param array $dependencies */ - private function installGlobal(array $dependencies) + private function installGlobal(array $dependencies): void { foreach ($dependencies as $package => $version) { if (!$this->isInstalledGlobally($package)) { @@ -66,14 +69,14 @@ private function installGlobal(array $dependencies) * * @return bool */ - private function isInstalledGlobally($package) + private function isInstalledGlobally(string $package): bool { if (!isset($this->globalList)) { - $this->globalList = $this->shell->execute( - ['npm', 'ls', '--global', '--no-progress', '--depth=0'] + $this->globalList = $this->shell->mustExecute( + ['npm', 'ls', '--global', '--no-progress', '--depth=0'], ); } - return $this->globalList && strpos($this->globalList, $package . '@') !== false; + return $this->globalList && str_contains((string) $this->globalList, $package . '@'); } } diff --git a/src/Local/DependencyManager/Pip.php b/src/Local/DependencyManager/Pip.php index 62e37454a6..becb86e1c3 100644 --- a/src/Local/DependencyManager/Pip.php +++ b/src/Local/DependencyManager/Pip.php @@ -1,23 +1,22 @@ stack = $stack; parent::__construct($shell); } /** * {@inheritdoc} */ - public function getInstallHelp() + public function getInstallHelp(): string { return 'See https://pip.pypa.io/en/stable/installing/ for installation instructions.'; } @@ -25,7 +24,7 @@ public function getInstallHelp() /** * {@inheritdoc} */ - public function getBinPaths($prefix) + public function getBinPaths($prefix): array { return [$prefix . '/bin']; } @@ -33,7 +32,7 @@ public function getBinPaths($prefix) /** * {@inheritdoc} */ - public function getCommandName() + public function getCommandName(): string { $commands = ['pip', 'pip3', 'pip2']; if ($this->stack === 'python3') { @@ -53,7 +52,7 @@ public function getCommandName() /** * {@inheritdoc} */ - public function install($path, array $dependencies, $global = false) + public function install($path, array $dependencies, $global = false): void { file_put_contents($path . '/requirements.txt', $this->formatRequirementsTxt($dependencies)); $command = $this->getCommandName() . ' install --requirement=requirements.txt'; @@ -66,7 +65,7 @@ public function install($path, array $dependencies, $global = false) /** * {@inheritdoc} */ - public function getEnvVars($path) + public function getEnvVars($path): array { $envVars = []; @@ -76,7 +75,7 @@ public function getEnvVars($path) if (file_exists($path . '/lib')) { $subdirectories = scandir($path . '/lib') ?: []; foreach ($subdirectories as $subdirectory) { - if (strpos($subdirectory, '.') !== 0) { + if (!str_starts_with($subdirectory, '.')) { $envVars['PYTHONPATH'] = $path . '/lib/' . $subdirectory . '/site-packages'; break; } @@ -87,11 +86,11 @@ public function getEnvVars($path) } /** - * @param array $dependencies + * @param array $dependencies * * @return string */ - private function formatRequirementsTxt(array $dependencies) + private function formatRequirementsTxt(array $dependencies): string { $lines = []; foreach ($dependencies as $package => $version) { diff --git a/src/Local/LocalApplication.php b/src/Local/LocalApplication.php index 389d3202a8..b7c8cb6ad3 100644 --- a/src/Local/LocalApplication.php +++ b/src/Local/LocalApplication.php @@ -1,6 +1,15 @@ appRoot = $appRoot; $this->sourceDir = $sourceDir ?: $appRoot; $this->mount = new Mount(); - $this->config = $appConfig; } /** @@ -49,21 +55,19 @@ public function __construct($appRoot, Config $cliConfig = null, $sourceDir = nul * * @return string */ - public function getId() + public function getId(): string { - return $this->getName() ?: $this->getPath() ?: 'default'; + return ($this->getName() ?: $this->getPath()) ?: 'default'; } /** * Returns the type of the app. - * - * @return string|null */ - public function getType() + public function getType(): ?string { $config = $this->getConfig(); - return isset($config['type']) ? $config['type'] : null; + return $config['type'] ?? null; } /** @@ -71,69 +75,52 @@ public function getType() * * @return bool */ - public function isSingle() + public function isSingle(): bool { - return $this->single; + return $this->single; } /** - * Set that this is is the only application in the project. - * - * @param bool $single + * Sets that this is is the only application in the project. */ - public function setSingle($single = true) + public function setSingle(bool $single = true): void { $this->single = $single; } /** - * Get the source directory where the application was found. + * Gets the source directory where the application was found. * * In a single-app project, this is usually the project root. - * - * @return string */ - public function getSourceDir() + public function getSourceDir(): string { return $this->sourceDir; } - /** - * @return string - */ - protected function getPath() + protected function getPath(): string { return str_replace($this->sourceDir . '/', '', $this->appRoot); } - /** - * @return string|null - */ - public function getName() + public function getName(): ?string { $config = $this->getConfig(); return !empty($config['name']) ? $config['name'] : null; } - /** - * @return string - */ - public function getRoot() + public function getRoot(): string { return $this->appRoot; } /** - * Get the absolute path to the local web root of this app. - * - * @param string|null $destination - * - * @return string + * Finds the absolute path to the local web root of this app. */ - public function getLocalWebRoot($destination = null) + public function getLocalWebRoot(?string $destination = null): string { - $destination = $destination ?: $this->getSourceDir() . '/' . $this->cliConfig->getWithDefault('local.web_root', '_www'); + $destination = $destination ?: $this->getSourceDir() . '/' . $this->cliConfig->getStr('local.web_root'); if ($this->isSingle()) { return $destination; } @@ -144,10 +131,10 @@ public function getLocalWebRoot($destination = null) /** * Get the application's configuration, parsed from its YAML definition. * - * @return array + * @return array * @throws \Exception */ - public function getConfig() + public function getConfig(): array { return $this->getConfigObject()->getNormalized(); } @@ -156,16 +143,14 @@ public function getConfig() * Get the application's configuration as an object. * * @throws InvalidConfigException if config is not found or invalid - * @throws \Symfony\Component\Yaml\Exception\ParseException if config cannot be parsed + * @throws ParseException if config cannot be parsed * @throws \Exception if the config file cannot be read - * - * @return AppConfig */ - private function getConfigObject() + private function getConfigObject(): AppConfig { if (!isset($this->config)) { if ($this->cliConfig->has('service.app_config_file')) { - $file = $this->appRoot . '/' . $this->cliConfig->get('service.app_config_file'); + $file = $this->appRoot . '/' . $this->cliConfig->getStr('service.app_config_file'); if (!file_exists($file)) { throw new InvalidConfigException('Configuration file not found: ' . $file); } @@ -180,11 +165,11 @@ private function getConfigObject() } /** - * Get a list of shared file mounts configured for the app. + * Gets a list of shared file mounts configured for the app. * - * @return array + * @return array */ - public function getSharedFileMounts() + public function getSharedFileMounts(): array { $config = $this->getConfig(); @@ -196,14 +181,14 @@ public function getSharedFileMounts() /** * @return BuildFlavorInterface[] */ - public function getBuildFlavors() + public function getBuildFlavors(): array { return [ - new BuildFlavor\Drupal(), - new BuildFlavor\Symfony(), - new BuildFlavor\Composer(), - new BuildFlavor\NodeJs(), - new BuildFlavor\NoBuildFlavor(), + new Drupal(), + new Symfony(), + new Composer(), + new NodeJs(), + new NoBuildFlavor(), ]; } @@ -211,18 +196,16 @@ public function getBuildFlavors() * Get the build flavor for the application. * * @throws InvalidConfigException If a build flavor is not found. - * - * @return BuildFlavorInterface */ - public function getBuildFlavor() + public function getBuildFlavor(): BuildFlavorInterface { $appConfig = $this->getConfig(); if (!isset($appConfig['type'])) { throw new InvalidConfigException('Application configuration key not found: `type`'); } - $key = isset($appConfig['build']['flavor']) ? $appConfig['build']['flavor'] : 'default'; - list($stack, ) = explode(':', $appConfig['type'], 2); + $key = $appConfig['build']['flavor'] ?? 'default'; + [$stack, ] = explode(':', (string) $appConfig['type'], 2); foreach (self::getBuildFlavors() as $candidate) { if (in_array($key, $candidate->getKeys()) && ($candidate->getStacks() === [] || in_array($stack, $candidate->getStacks()))) { @@ -233,25 +216,17 @@ public function getBuildFlavor() } /** - * Get the configured document root for the application, as a relative path. - * - * @param string $default - * - * @todo stop using 'public' as the default - * - * @return string + * Finds the configured document root for the application, as a relative path. */ - public function getDocumentRoot($default = 'public') + public function getDocumentRoot(string $default = 'public'): string { return $this->getConfigObject()->getDocumentRoot() ?: $default; } /** - * Check whether the whole app should be moved into the document root. - * - * @return bool + * Checks whether the whole app should be moved into the document root. */ - public function shouldMoveToRoot() + public function shouldMoveToRoot(): bool { $config = $this->getConfig(); diff --git a/src/Local/LocalBuild.php b/src/Local/LocalBuild.php index a7eac1cc93..9efe112c0a 100644 --- a/src/Local/LocalBuild.php +++ b/src/Local/LocalBuild.php @@ -1,6 +1,10 @@ */ + protected array $settings = []; - /** @var OutputInterface */ - protected $output; + protected OutputInterface $output; - /** @var Filesystem */ - protected $fsHelper; + protected Filesystem $fsHelper; - /** @var Git */ - protected $gitHelper; + protected Git $gitHelper; - /** @var Shell */ - protected $shellHelper; + protected Shell $shellHelper; - /** @var DependencyInstaller */ - protected $dependencyInstaller; + protected DependencyInstaller $dependencyInstaller; - /** @var Config */ - protected $config; + protected Config $config; - /** @var ApplicationFinder */ - protected $applicationFinder; + protected ApplicationFinder $applicationFinder; /** * LocalBuild constructor. * * @param Config|null $config * @param OutputInterface|null $output - * @param \Platformsh\Cli\Service\Shell|null $shell - * @param \Platformsh\Cli\Service\Filesystem|null $fs - * @param \Platformsh\Cli\Service\Git|null $git - * @param \Platformsh\Cli\Local\DependencyInstaller|null $dependencyInstaller + * @param Shell|null $shell + * @param Filesystem|null $fs + * @param Git|null $git + * @param DependencyInstaller|null $dependencyInstaller */ public function __construct( - Config $config = null, - OutputInterface $output = null, - Shell $shell = null, - Filesystem $fs = null, - Git $git = null, - DependencyInstaller $dependencyInstaller = null, - ApplicationFinder $applicationFinder = null + ?Config $config = null, + ?OutputInterface $output = null, + ?Shell $shell = null, + ?Filesystem $fs = null, + ?Git $git = null, + ?DependencyInstaller $dependencyInstaller = null, + ?ApplicationFinder $applicationFinder = null, ) { $this->config = $config ?: new Config(); $this->output = $output ?: new ConsoleOutput(); @@ -75,7 +71,7 @@ public function __construct( /** * Build a project from any source directory, targeting any destination. * - * @param array $settings An array of build settings. + * @param array $settings An array of build settings. * Possible settings: * - clone (bool, default false) Clone the repository to the build * directory before building, where possible. @@ -99,16 +95,15 @@ public function __construct( * file via Drush Make, if applicable. * - run-deploy-hooks (bool, default false) Run deploy and/or * post_deploy hooks. - * @param string $sourceDir The absolute path to the source directory. - * @param string $destination Where the web root(s) will be linked (absolute - * path). - * @param array $apps An array of application names to build. - * - * @throws \Exception on failure + * @param string $sourceDir The absolute path to the source directory. + * @param ?string $destination Where the web root(s) will be linked + * (absolute path). + * @param string[] $apps An array of application names to build. * * @return bool + * @throws \Exception on failure */ - public function build(array $settings, $sourceDir, $destination = null, array $apps = []) + public function build(array $settings, string $sourceDir, ?string $destination = null, array $apps = []): bool { $this->settings = $settings; $this->fsHelper->setRelativeLinks(empty($settings['abslinks'])); @@ -143,17 +138,14 @@ public function build(array $settings, $sourceDir, $destination = null, array $a } /** - * Get a hash of the application files. + * Calculates a hash of the application files. * * This should change if any of the application files or build settings * change. * - * @param string $appRoot - * @param array $settings - * - * @return string|false + * @param array $settings */ - public function getTreeId($appRoot, array $settings) + public function getTreeId(string $appRoot, array $settings): false|string { $hashes = []; @@ -164,11 +156,11 @@ public function getTreeId($appRoot, array $settings) return false; } $tree = preg_replace( - '#^|\n[^\n]+?' . preg_quote($this->config->get('service.project_config_dir')) . '\n|$#', + '#^|\n[^\n]+?' . preg_quote($this->config->getStr('service.project_config_dir')) . '\n|$#', "\n", - $tree + $tree, ); - $hashes[] = sha1($tree); + $hashes[] = sha1((string) $tree); // Include the hashes of untracked and modified files. $others = $this->gitHelper->execute( @@ -177,10 +169,10 @@ public function getTreeId($appRoot, array $settings) '--modified', '--others', '--exclude-standard', - '-x ' . $this->config->get('service.project_config_dir'), + '-x ' . $this->config->getStr('service.project_config_dir'), '.', ], - $appRoot + $appRoot, ); if ($others === false) { return false; @@ -209,19 +201,14 @@ public function getTreeId($appRoot, array $settings) } /** - * Build a single application. - * - * @param LocalApplication $app - * @param string|null $destination - * - * @return bool + * Builds a single application. */ - protected function buildApp($app, $destination = null) + protected function buildApp(LocalApplication $app, ?string $destination = null): bool { $verbose = $this->output->isVerbose(); $sourceDir = $app->getSourceDir(); - $destination = $destination ?: $sourceDir . '/' . $this->config->getWithDefault('local.web_root', '_www'); + $destination = $destination ?: $sourceDir . '/' . $this->config->getStr('local.web_root'); $appRoot = $app->getRoot(); $appConfig = $app->getConfig(); $appId = $app->getId(); @@ -231,7 +218,7 @@ protected function buildApp($app, $destination = null) // Find the right build directory. $buildName = $app->isSingle() ? 'default' : str_replace('/', '-', $appId); - $tmpBuildDir = $sourceDir . '/' . $this->config->get('local.build_dir') . '/' . $buildName . '-tmp'; + $tmpBuildDir = $sourceDir . '/' . $this->config->getStr('local.build_dir') . '/' . $buildName . '-tmp'; if (file_exists($tmpBuildDir)) { if (!$this->fsHelper->remove($tmpBuildDir)) { @@ -243,22 +230,22 @@ protected function buildApp($app, $destination = null) // If the destination is inside the source directory, ensure it isn't // copied or symlinked into the build. - if (strpos($destination, $sourceDir) === 0) { + if (str_starts_with($destination, $sourceDir)) { $buildFlavor->addIgnoredFiles([ ltrim(substr($destination, strlen($sourceDir)), '/'), ]); } // Warn about a mismatched PHP version. - if (isset($appConfig['type']) && strpos($appConfig['type'], ':')) { - list($stack, $version) = explode(':', $appConfig['type'], 2); + if (isset($appConfig['type']) && strpos((string) $appConfig['type'], ':')) { + [$stack, $version] = explode(':', (string) $appConfig['type'], 2); $localPhpVersion = $this->shellHelper->getPhpVersion(); if ($stack === 'php' && version_compare($version, $localPhpVersion, '>')) { $this->output->writeln(sprintf( 'Warning: the application %s expects PHP %s, but the system version is %s.', $appId, $version, - $localPhpVersion + $localPhpVersion, )); } } @@ -274,7 +261,7 @@ protected function buildApp($app, $destination = null) if ($verbose) { $this->output->writeln("Tree ID: $treeId"); } - $archive = $sourceDir . '/' . $this->config->get('local.archive_dir') . '/' . $treeId . '.tar.gz'; + $archive = $sourceDir . '/' . $this->config->getStr('local.archive_dir') . '/' . $treeId . '.tar.gz'; } } @@ -293,15 +280,14 @@ protected function buildApp($app, $destination = null) // Install dependencies. if (isset($appConfig['dependencies'])) { - $depsDir = $sourceDir . '/' . $this->config->get('local.dependencies_dir'); + $depsDir = $sourceDir . '/' . $this->config->getStr('local.dependencies_dir'); if (!empty($this->settings['no-deps'])) { $this->output->writeln('Skipping build dependencies'); } else { - $result = $this->dependencyInstaller->installDependencies( + $success = $this->dependencyInstaller->installDependencies( $depsDir, - $appConfig['dependencies'] + $appConfig['dependencies'], ); - $success = $success && $result; } // Use the dependencies' PATH and other environment variables @@ -344,7 +330,7 @@ protected function buildApp($app, $destination = null) if (!rename($tmpBuildDir, $buildDir)) { $this->output->writeln(sprintf( 'Failed to move temporary build directory into %s', - $buildDir + $buildDir, )); return false; @@ -375,16 +361,15 @@ protected function buildApp($app, $destination = null) } /** - * Run post-build hooks. + * Runs post-build hooks. * - * @param array $appConfig - * @param string $buildDir + * @param array $appConfig * * @return bool|null * False if the build hooks fail, true if they succeed, null if not * applicable. */ - protected function runPostBuildHooks(array $appConfig, $buildDir) + protected function runPostBuildHooks(array $appConfig, string $buildDir): bool|null { if (!isset($appConfig['hooks']['build'])) { return null; @@ -399,16 +384,15 @@ protected function runPostBuildHooks(array $appConfig, $buildDir) } /** - * Run deploy and post_deploy hooks. + * Runs deploy and post_deploy hooks. * - * @param array $appConfig - * @param string $appDir + * @param array $appConfig * * @return bool|null * False if the deploy hooks fail, true if they succeed, null if not * applicable. */ - protected function runDeployHooks(array $appConfig, $appDir) + protected function runDeployHooks(array $appConfig, string $appDir): bool|null { if (empty($this->settings['run-deploy-hooks'])) { return null; @@ -431,18 +415,15 @@ protected function runDeployHooks(array $appConfig, $appDir) } /** - * Run a user-defined hook. - * - * @param string|array $hook - * @param string $dir + * Runs a user-defined hook. * - * @return bool + * @param string|string[] $hook */ - protected function runHook($hook, $dir) + private function runHook(string|array $hook, string $dir): bool { $code = $this->shellHelper->executeSimple( implode("\n", (array) $hook), - $dir + $dir, ); if ($code !== 0) { $this->output->writeln("The hook failed with the exit code: $code"); @@ -457,16 +438,12 @@ protected function runHook($hook, $dir) * * This preserves the currently active build. * - * @param string $projectRoot - * @param int|null $maxAge - * @param int $keepMax - * @param bool $includeActive - * @param bool $quiet - * * @return int[] * The numbers of deleted and kept builds. + * + * @throws \Exception */ - public function cleanBuilds($projectRoot, $maxAge = null, $keepMax = 10, $includeActive = false, $quiet = true) + public function cleanBuilds(string $projectRoot, ?int $maxAge = null, int $keepMax = 10, bool $includeActive = false, bool $quiet = true): array { // Find all the potentially active symlinks, which might be www itself // or symlinks inside www. This is so we can avoid deleting the active @@ -477,11 +454,11 @@ public function cleanBuilds($projectRoot, $maxAge = null, $keepMax = 10, $includ } return $this->cleanDirectory( - $projectRoot . '/' . $this->config->get('local.build_dir'), + $projectRoot . '/' . $this->config->getStr('local.build_dir'), $maxAge, $keepMax, $exclude, - $quiet + $quiet, ); } @@ -491,18 +468,18 @@ public function cleanBuilds($projectRoot, $maxAge = null, $keepMax = 10, $includ * @throws \Exception If it cannot be determined whether or not a symlink * points to a genuine active build. * - * @return array The absolute paths to any active builds in the project. + * @return string[] The absolute paths to any active builds in the project. */ - protected function getActiveBuilds($projectRoot) + protected function getActiveBuilds(string $projectRoot): array { - $www = $projectRoot . '/' . $this->config->getWithDefault('local.web_root', '_www'); + $www = $projectRoot . '/' . $this->config->getStr('local.web_root'); if (!file_exists($www)) { return []; } $links = [$www]; if (!is_link($www) && is_dir($www)) { $finder = new Finder(); - /** @var \Symfony\Component\Finder\SplFileInfo $file */ + /** @var SplFileInfo $file */ foreach ($finder->in($www) ->directories() ->depth(0) as $file) { @@ -510,7 +487,7 @@ protected function getActiveBuilds($projectRoot) } } $activeBuilds = []; - $buildsDir = $projectRoot . '/' . $this->config->get('local.build_dir'); + $buildsDir = $projectRoot . '/' . $this->config->getStr('local.build_dir'); foreach ($links as $link) { if (is_link($link) && ($target = readlink($link))) { // Make the target into an absolute path. @@ -519,14 +496,14 @@ protected function getActiveBuilds($projectRoot) continue; } // Ignore the target if it doesn't point to a build in 'builds'. - if (strpos($target, $buildsDir) === false) { + if (!str_contains($target, $buildsDir)) { continue; } // The target should just be one level below the 'builds' // directory, not more. while (dirname($target) != $buildsDir) { $target = dirname($target); - if (strpos($target, $buildsDir) === false) { + if (!str_contains($target, $buildsDir)) { throw new \Exception('Error resolving active build directory'); } } @@ -538,24 +515,24 @@ protected function getActiveBuilds($projectRoot) } /** - * Remove old build archives. + * Removes old build archives. * - * @param string $projectRoot + * @param string $projectRoot * @param int|null $maxAge - * @param int $keepMax - * @param bool $quiet + * @param int $keepMax + * @param bool $quiet * * @return int[] * The numbers of deleted and kept builds. */ - public function cleanArchives($projectRoot, $maxAge = null, $keepMax = 10, $quiet = true) + public function cleanArchives(string $projectRoot, ?int $maxAge = null, int $keepMax = 10, bool $quiet = true): array { return $this->cleanDirectory( - $projectRoot . '/' . $this->config->get('local.archive_dir'), + $projectRoot . '/' . $this->config->getStr('local.archive_dir'), $maxAge, $keepMax, [], - $quiet + $quiet, ); } @@ -565,12 +542,12 @@ public function cleanArchives($projectRoot, $maxAge = null, $keepMax = 10, $quie * @param string $directory * @param int|null $maxAge * @param int $keepMax - * @param array $exclude + * @param string[] $exclude * @param bool $quiet * * @return int[] */ - protected function cleanDirectory($directory, $maxAge = null, $keepMax = 5, array $exclude = [], $quiet = true) + protected function cleanDirectory(string $directory, ?int $maxAge = null, int $keepMax = 5, array $exclude = [], bool $quiet = true): array { if (!is_dir($directory)) { return [0, 0]; @@ -582,9 +559,7 @@ protected function cleanDirectory($directory, $maxAge = null, $keepMax = 5, arra // Sort files by modified time (descending). usort( $files, - function ($a, $b) { - return filemtime($a) < filemtime($b); - } + fn(string $a, string $b): int => filemtime($a) <=> filemtime($b), ); $now = time(); $numDeleted = 0; diff --git a/src/Local/LocalProject.php b/src/Local/LocalProject.php index 522812da14..af9b343cd3 100644 --- a/src/Local/LocalProject.php +++ b/src/Local/LocalProject.php @@ -1,26 +1,33 @@ > */ + protected static array $projectConfigs = []; - public function __construct(Config $config = null, Git $git = null) + public function __construct(?Config $config = null, ?Git $git = null, ?Io $io = null) { $this->config = $config ?: new Config(); $this->git = $git ?: new Git(); + $this->io = $io ?: new Io(new ConsoleOutput()); $this->fs = new Filesystem(); } @@ -30,29 +37,27 @@ public function __construct(Config $config = null, Git $git = null) * @param string $dir The project root. * @param string $configFile A config file such as 'services.yaml'. * - * @return array|null + * @return array|null */ - public function readProjectConfigFile($dir, $configFile) + public function readProjectConfigFile(string $dir, string $configFile): ?array { $result = null; - $filename = $dir . '/' . $this->config->get('service.project_config_dir') . '/' . $configFile; + $filename = $dir . '/' . $this->config->getStr('service.project_config_dir') . '/' . $configFile; if (file_exists($filename)) { $parser = new Parser(); - $result = $parser->parse(file_get_contents($filename)); + $result = $parser->parse((string) file_get_contents($filename)); } return $result; } /** - * @param string $gitUrl - * - * @return array|false - * An array containing 'id' and 'host', or false on failure. + * @return array{id: string, host: string}|false + * The project ID and hostname, or false on failure. */ - public function parseGitUrl($gitUrl) + public function parseGitUrl(string $gitUrl): false|array { - $gitDomain = $this->config->get('detection.git_domain'); + $gitDomain = $this->config->getStr('detection.git_domain'); $pattern = '/^([a-z0-9]{12,})@git\.(([a-z0-9\-]+\.)?' . preg_quote($gitDomain) . '):\1\.git$/'; if (!preg_match($pattern, $gitUrl, $matches)) { return false; @@ -62,6 +67,8 @@ public function parseGitUrl($gitUrl) } /** + * Finds the git remote URL for a repository. + * * @param string $dir * * @throws \RuntimeException @@ -70,10 +77,10 @@ public function parseGitUrl($gitUrl) * @return string|false * The Git remote URL. */ - protected function getGitRemoteUrl($dir) + protected function getGitRemoteUrl(string $dir): string|false { $this->git->ensureInstalled(); - foreach ([$this->config->get('detection.git_remote_name'), 'origin'] as $remote) { + foreach ([$this->config->getStr('detection.git_remote_name'), 'origin'] as $remote) { if ($url = $this->git->getConfig("remote.$remote.url", $dir)) { return $url; } @@ -90,28 +97,28 @@ protected function getGitRemoteUrl($dir) * @param string $url * The Git URL. */ - public function ensureGitRemote($dir, $url) + public function ensureGitRemote(string $dir, string $url): void { if (!file_exists($dir . '/.git')) { throw new \InvalidArgumentException('The directory is not a Git repository'); } $this->git->ensureInstalled(); $currentUrl = $this->git->getConfig( - sprintf('remote.%s.url', $this->config->get('detection.git_remote_name')), - $dir + sprintf('remote.%s.url', $this->config->getStr('detection.git_remote_name')), + $dir, ); if (!$currentUrl) { $this->git->execute( - ['remote', 'add', $this->config->get('detection.git_remote_name'), $url], + ['remote', 'add', $this->config->getStr('detection.git_remote_name'), $url], $dir, - true + true, ); } elseif ($currentUrl !== $url) { $this->git->execute([ 'remote', 'set-url', - $this->config->get('detection.git_remote_name'), - $url + $this->config->getStr('detection.git_remote_name'), + $url, ], $dir, true); } } @@ -121,9 +128,10 @@ public function ensureGitRemote($dir, $url) * * @param string $file * The filename to look for. - * @param string $startDir + * @param ?string $startDir * An absolute path to a directory to start in. - * @param callable $callback + * Defaults to the current directory. + * @param ?callable $callback * A callback to validate the directory when found. Accepts one argument * (the directory path). Return true to use the directory, or false to * continue traversing upwards. @@ -132,7 +140,7 @@ public function ensureGitRemote($dir, $url) * The path to the directory, or false if the file is not found. Where * possible this will be an absolute, real path. */ - protected static function findTopDirectoryContaining($file, $startDir = null, callable $callback = null) + protected static function findTopDirectoryContaining(string $file, ?string $startDir = null, ?callable $callback = null): string|false { static $roots = []; $startDir = $startDir ?: getcwd(); @@ -147,7 +155,7 @@ protected static function findTopDirectoryContaining($file, $startDir = null, ca $root = &$roots[$startDir][$file]; $currentDir = $startDir; - while (!$root) { + while (true) { if (file_exists($currentDir . '/' . $file)) { if ($callback === null || $callback($currentDir)) { $root = $currentDir; @@ -178,7 +186,7 @@ protected static function findTopDirectoryContaining($file, $startDir = null, ca * @param Project $project * The project. */ - public function mapDirectory($directory, Project $project) + public function mapDirectory(string $directory, Project $project): void { if (!file_exists($directory . '/.git')) { throw new \InvalidArgumentException('Not a Git repository: ' . $directory); @@ -194,66 +202,73 @@ public function mapDirectory($directory, Project $project) } /** - * Find the legacy root of the current project, from CLI versions <3. - * - * @param string|null $startDir - * - * @return string|false + * Finds the legacy root of the current project, from CLI versions <3. */ - public function getLegacyProjectRoot($startDir = null) + public function getLegacyProjectRoot(?string $startDir = null): string|false { if (!$this->config->has('local.project_config_legacy')) { return false; } - return $this->findTopDirectoryContaining($this->config->get('local.project_config_legacy'), $startDir); + return $this->findTopDirectoryContaining($this->config->getStr('local.project_config_legacy'), $startDir); } /** - * Find the root of the current project. - * - * @param string|null $startDir - * - * @return string|false + * Finds the root of the current project. */ - public function getProjectRoot($startDir = null) + public function getProjectRoot(?string $startDir = null): string|false { $startDir = $startDir ?: getcwd(); + if ($startDir === false) { + throw new \RuntimeException('Failed to determine current working directory'); + } + + static $cache = []; + if (isset($cache[$startDir])) { + return $cache[$startDir]; + } + + $this->io->debug('Finding the project root'); // Backwards compatibility - if in an old-style project root, change // directory to the repository. - if (is_dir($startDir . '/repository') && $this->config->has('local.project_config_legacy') && file_exists($startDir . '/' . $this->config->get('local.project_config_legacy'))) { + if (is_dir($startDir . '/repository') && $this->config->has('local.project_config_legacy') && file_exists($startDir . '/' . $this->config->getStr('local.project_config_legacy'))) { $startDir = $startDir . '/repository'; } // The project root is a Git repository, which contains a project // configuration file, and/or contains a Git remote with the appropriate // domain. - return $this->findTopDirectoryContaining('.git', $startDir, function ($dir) { + $result = $this->findTopDirectoryContaining('.git', $startDir, function ($dir): bool { $config = $this->getProjectConfig($dir); return !empty($config); }); + $this->io->debug( + $result ? 'Project root found: ' . $result : 'Project root not found', + ); + + return $cache[$startDir] = $result; } /** - * Get the configuration for the current project. - * - * @param string|null $projectRoot + * Gets the configuration for the current project. * - * @return array|null + * @return array|null * The current project's configuration. + * + * @throws \Exception */ - public function getProjectConfig($projectRoot = null) + public function getProjectConfig(?string $projectRoot = null): array|null { $projectRoot = $projectRoot ?: $this->getProjectRoot(); if (isset(self::$projectConfigs[$projectRoot])) { return self::$projectConfigs[$projectRoot]; } $projectConfig = null; - $configFilename = $this->config->get('local.project_config'); + $configFilename = $this->config->getStr('local.project_config'); if ($projectRoot && file_exists($projectRoot . '/' . $configFilename)) { $yaml = new Parser(); - $projectConfig = $yaml->parse(file_get_contents($projectRoot . '/' . $configFilename)); + $projectConfig = $yaml->parse((string) file_get_contents($projectRoot . '/' . $configFilename)); self::$projectConfigs[$projectRoot] = $projectConfig; } elseif ($projectRoot && is_dir($projectRoot . '/.git')) { $gitUrl = $this->getGitRemoteUrl($projectRoot); @@ -266,31 +281,31 @@ public function getProjectConfig($projectRoot = null) } /** - * Write configuration for a project. + * Writes configuration for a project. * * Configuration is stored as YAML, in the location configured by * 'local.project_config'. * - * @param array $config + * @param array $config * The configuration. - * @param string $projectRoot + * @param ?string $projectRoot * The project root. - * @param bool $merge + * @param bool $merge * Whether to merge with existing configuration. * * @throws \Exception On failure * - * @return array + * @return array * The updated project configuration. */ - public function writeCurrentProjectConfig(array $config, $projectRoot = null, $merge = false) + public function writeCurrentProjectConfig(array $config, ?string $projectRoot = null, bool $merge = false): array { $projectRoot = $projectRoot ?: $this->getProjectRoot(); if (!$projectRoot) { throw new \Exception('Project root not found'); } $this->ensureLocalDir($projectRoot); - $file = $projectRoot . '/' . $this->config->get('local.project_config'); + $file = $projectRoot . '/' . $this->config->getStr('local.project_config'); if ($merge) { $projectConfig = $this->getProjectConfig($projectRoot) ?: []; $config = array_merge($projectConfig, $config); @@ -306,30 +321,32 @@ public function writeCurrentProjectConfig(array $config, $projectRoot = null, $m /** * @param string $projectRoot */ - public function ensureLocalDir($projectRoot) + public function ensureLocalDir(string $projectRoot): void { - $localDirRelative = $this->config->get('local.local_dir'); + $localDirRelative = $this->config->getStr('local.local_dir'); $dir = $projectRoot . '/' . $localDirRelative; if (!is_dir($dir)) { - mkdir($dir, 0755, true); + mkdir($dir, 0o755, true); } $this->writeGitExclude($projectRoot); if (!file_exists($dir . '/.gitignore')) { file_put_contents($dir . '/.gitignore', '/' . PHP_EOL); } if (!file_exists($dir . '/README.txt')) { - $cliName = $this->config->get('application.name'); - file_put_contents($dir . '/README.txt', <<config->getStr('application.name'); + file_put_contents( + $dir . '/README.txt', + <<config->get('local.local_dir'), '/' . $this->config->getWithDefault('local.web_root', '_www')]; + $filesToExclude = ['/' . $this->config->getStr('local.local_dir'), '/' . $this->config->getStr('local.web_root')]; $excludeFilename = $dir . '/.git/info/exclude'; $existing = ''; // Skip writing anything if the contents already include the // application.name. if (file_exists($excludeFilename)) { - $existing = file_get_contents($excludeFilename); - if (strpos($existing, $this->config->get('application.name')) !== false) { + $existing = (string) file_get_contents($excludeFilename); + if (str_contains($existing, $this->config->getStr('application.name'))) { // Backwards compatibility between versions 3.0.0 and 3.0.2. - $newRoot = "\n" . '/' . $this->config->get('application.name') . "\n"; + $newRoot = "\n" . '/' . $this->config->getStr('application.name') . "\n"; $oldRoot = "\n" . '/.www' . "\n"; - if (strpos($existing, $oldRoot) !== false && strpos($existing, $newRoot) === false) { + if (str_contains($existing, $oldRoot) && !str_contains($existing, $newRoot)) { $this->fs->dumpFile($excludeFilename, str_replace($oldRoot, $newRoot, $existing)); } if (is_link($dir . '/.www')) { @@ -365,7 +382,7 @@ public function writeGitExclude($dir) } } - $content = "# Automatically added by the " . $this->config->get('application.name') . "\n" + $content = "# Automatically added by the " . $this->config->getStr('application.name') . "\n" . implode("\n", $filesToExclude) . "\n"; if (!empty($existing)) { diff --git a/src/Model/Activity.php b/src/Model/Activity.php index ee52ab7a52..3e1100a91c 100644 --- a/src/Model/Activity.php +++ b/src/Model/Activity.php @@ -1,25 +1,22 @@ isComplete()) { $end = strtotime($activity->completed_at); } elseif ($activity->state === ApiActivity::STATE_CANCELLED && $activity->hasProperty('cancelled_at')) { - $end = strtotime($activity->getProperty('cancelled_at')); + $end = strtotime((string) $activity->getProperty('cancelled_at')); } elseif (!empty($activity->started_at)) { $now = $now === null ? time() : $now; $end = $now; diff --git a/src/Model/AppConfig.php b/src/Model/AppConfig.php index ad19798cd2..a92b791854 100644 --- a/src/Model/AppConfig.php +++ b/src/Model/AppConfig.php @@ -1,5 +1,7 @@ */ + private array $normalizedConfig; /** * AppConfig constructor. * - * @param array $config + * @param array $config */ - public function __construct(array $config) + public function __construct(private readonly array $config) { - $this->config = $config; - $this->normalizedConfig = $this->normalizeConfig($config); + $this->normalizedConfig = $this->normalizeConfig($this->config); } /** - * @param \Platformsh\Client\Model\Deployment\WebApp $app + * @param WebApp $app * - * @return static + * @return self */ - public static function fromWebApp(WebApp $app) + public static function fromWebApp(WebApp $app): self { - return new static($app->getProperties()); + return new self($app->getProperties()); } /** * Get normalized configuration. * - * @return array + * @return array */ - public function getNormalized() + public function getNormalized(): array { if (!isset($this->normalizedConfig)) { $this->normalizedConfig = $this->normalizeConfig($this->config); @@ -53,7 +54,7 @@ public function getNormalized() * * @return string */ - public function getDocumentRoot() + public function getDocumentRoot(): string { $documentRoot = ''; $normalized = $this->getNormalized(); @@ -69,25 +70,25 @@ public function getDocumentRoot() } } - return ltrim($documentRoot, '/'); + return ltrim((string) $documentRoot, '/'); } /** * Normalize the application config. * - * @param array $config + * @param array $config * - * @return array + * @return array */ - private function normalizeConfig(array $config) + private function normalizeConfig(array $config): array { // Backwards compatibility with old config format: `toolstack` is // changed to application `type` and `build`.`flavor`. if (isset($config['toolstack'])) { - if (!strpos($config['toolstack'], ':')) { + if (!strpos((string) $config['toolstack'], ':')) { throw new InvalidConfigException("Invalid value for 'toolstack'"); } - list($config['type'], $config['build']['flavor']) = explode(':', $config['toolstack'], 2); + [$config['type'], $config['build']['flavor']] = explode(':', (string) $config['toolstack'], 2); } // The `web` section has changed to `web`.`locations`. @@ -115,9 +116,9 @@ private function normalizeConfig(array $config) } /** - * @return array + * @return array */ - private function getOldWebDefaults() + private function getOldWebDefaults(): array { return [ 'document_root' => '/public', diff --git a/src/Model/EnvironmentDomain.php b/src/Model/EnvironmentDomain.php index 1fe2f05a47..9bbe275893 100644 --- a/src/Model/EnvironmentDomain.php +++ b/src/Model/EnvironmentDomain.php @@ -1,11 +1,14 @@ $ssl */ -class EnvironmentDomain extends Resource +class EnvironmentDomain extends ApiResourceBase { - public static function getList(Environment $environment, ClientInterface $client) + /** @return EnvironmentDomain[] */ + public static function getList(Environment $environment, ClientInterface $client): array { return static::getCollection($environment->getLink('#domains'), 0, [], $client); } /** - * @param ClientInterface $client - * @param Environment $environment - * @param string $name - * @param string $replacementFor - * @param array $ssl - * @return \Platformsh\Client\Model\Result + * Adds a domain to an environment. + * + * @param array $ssl */ - public static function add(ClientInterface $client, Environment $environment, $name, $replacementFor = '', $ssl = []) + public static function add(ClientInterface $client, Environment $environment, string $name, string $replacementFor = '', array $ssl = []): Result { $body = ['name' => $name]; if (!empty($replacementFor)) { diff --git a/src/Model/Host/HostInterface.php b/src/Model/Host/HostInterface.php index 275fe3904b..d805e37b4c 100644 --- a/src/Model/Host/HostInterface.php +++ b/src/Model/Host/HostInterface.php @@ -1,7 +1,11 @@ shell = $shell ?: new Shell(); } @@ -17,7 +19,7 @@ public function __construct(Shell $shell = null) /** * {@inheritDoc} */ - public function getLabel() + public function getLabel(): string { return 'localhost'; } @@ -31,7 +33,7 @@ public function getLabel() * @return bool True if there is a conflict, or false if the local host can * be safely used. */ - public static function conflictsWithCommandLineOptions(InputInterface $input, $envPrefix) + public static function conflictsWithCommandLineOptions(InputInterface $input, string $envPrefix): bool { $map = [ 'PROJECT' => 'project', @@ -52,28 +54,22 @@ public static function conflictsWithCommandLineOptions(InputInterface $input, $e /** * {@inheritDoc} */ - public function getCacheKey() + public function getCacheKey(): string { return 'localhost'; } - public function lastChanged() + public function lastChanged(): string { return ''; } - /** - * {@inheritDoc} - */ - public function runCommand($command, $mustRun = true, $quiet = true, $input = null) + public function runCommand(string $command, bool $mustRun = true, bool $quiet = true, mixed $input = null): false|string { - return $this->shell->execute($command, null, $mustRun, $quiet, [], 3600, $input); + return $this->shell->execute($command, mustRun: $mustRun, quiet: $quiet, input: $input); } - /** - * {@inheritDoc} - */ - public function runCommandDirect($commandLine, $append = '') + public function runCommandDirect(string $commandLine, string $append = ''): int { return $this->shell->executeSimple($commandLine . $append); } diff --git a/src/Model/Host/RemoteHost.php b/src/Model/Host/RemoteHost.php index d0c75a7571..0bd6db4375 100644 --- a/src/Model/Host/RemoteHost.php +++ b/src/Model/Host/RemoteHost.php @@ -1,5 +1,7 @@ sshUrl = $sshUrl; - $this->environment = $environment; - $this->sshService = $sshService; - $this->shell = $shell; - $this->sshDiagnostics = $sshDiagnostics; - } + public function __construct(private readonly string $sshUrl, private readonly Environment $environment, private readonly Ssh $sshService, private readonly Shell $shell, private readonly SshDiagnostics $sshDiagnostics) {} - /** - * {@inheritDoc} - */ - public function getLabel() + public function getLabel(): string { return $this->sshUrl; } @@ -37,7 +25,7 @@ public function getLabel() /** * @param string[] $options */ - public function setExtraSshOptions(array $options) + public function setExtraSshOptions(array $options): void { $this->extraSshOptions = $options; } @@ -45,10 +33,10 @@ public function setExtraSshOptions(array $options) /** * {@inheritDoc} */ - public function runCommand($command, $mustRun = true, $quiet = true, $input = null) + public function runCommand(string $command, bool $mustRun = true, bool $quiet = true, mixed $input = null): false|string { try { - return $this->shell->execute($this->wrapCommandLine($command), null, $mustRun, $quiet, $this->sshService->getEnv(), 3600, $input); + return $this->shell->execute($this->wrapCommandLine($command), mustRun: $mustRun, quiet: $quiet, env: $this->sshService->getEnv(), input: $input); } catch (ProcessFailedException $e) { $this->sshDiagnostics->diagnoseFailure($this->sshUrl, $e->getProcess(), !$quiet); throw new ProcessFailedException($e->getProcess(), false); @@ -62,15 +50,12 @@ public function runCommand($command, $mustRun = true, $quiet = true, $input = nu * * @return string */ - private function wrapCommandLine($commandLine) + private function wrapCommandLine(string $commandLine): string { return $this->sshService->getSshCommand($this->sshUrl, $this->extraSshOptions, $commandLine); } - /** - * {@inheritDoc} - */ - public function runCommandDirect($commandLine, $append = '') + public function runCommandDirect($commandLine, $append = ''): int { $start = \time(); $exitCode = $this->shell->executeSimple($this->wrapCommandLine($commandLine) . $append, null, $this->sshService->getEnv()); @@ -78,17 +63,14 @@ public function runCommandDirect($commandLine, $append = '') return $exitCode; } - /** - * {@inheritDoc} - */ - public function getCacheKey() + public function getCacheKey(): string { return $this->sshUrl . '--' . $this->environment->head_commit; } - public function lastChanged() + public function lastChanged(): string { $deployment_state = $this->environment->getProperty('deployment_state', false, false); - return isset($deployment_state['last_deployment_at']) ? $deployment_state['last_deployment_at'] : ''; + return $deployment_state['last_deployment_at'] ?? ''; } } diff --git a/src/Model/Metrics/Field.php b/src/Model/Metrics/Field.php index 05408dfb16..7bff28703c 100644 --- a/src/Model/Metrics/Field.php +++ b/src/Model/Metrics/Field.php @@ -1,33 +1,25 @@ name = $name; - $this->format = $format; - } + public function __construct(private readonly string $name, private readonly string $format) {} - public function getName() + public function getName(): string { return $this->name; } @@ -40,7 +32,7 @@ public function getName() * * @return string */ - private function formatPercent($pc, $warn = true) + private function formatPercent(float $pc, bool $warn = true): string { if ($warn) { if ($pc >= self::RED_WARNING_THRESHOLD) { @@ -62,22 +54,17 @@ private function formatPercent($pc, $warn = true) * * @return string */ - public function format(Sketch $value, $warn = true) + public function format(Sketch $value, bool $warn = true): string { if ($value->isInfinite()) { return '∞'; } - switch ($this->format) { - case self::FORMAT_ROUNDED: - return (string) round($value->average()); - case self::FORMAT_ROUNDED_2DP: - return (string) round($value->average(), 2); - case self::FORMAT_PERCENT: - return $this->formatPercent($value->average(), $warn); - case self::FORMAT_DISK: - case self::FORMAT_MEMORY: - return FormatterHelper::formatMemory($value->average()); - } - throw new \InvalidArgumentException('Formatter not found: ' . $this->format); + return match ($this->format) { + self::FORMAT_ROUNDED => (string) round($value->average()), + self::FORMAT_ROUNDED_2DP => (string) round($value->average(), 2), + self::FORMAT_PERCENT => $this->formatPercent($value->average(), $warn), + self::FORMAT_DISK, self::FORMAT_MEMORY => FormatterHelper::formatMemory((int) $value->average()), + default => throw new \InvalidArgumentException('Formatter not found: ' . $this->format), + }; } } diff --git a/src/Model/Metrics/Query.php b/src/Model/Metrics/Query.php index 371ca924a6..7bca30a494 100644 --- a/src/Model/Metrics/Query.php +++ b/src/Model/Metrics/Query.php @@ -1,82 +1,58 @@ */ + private array $fields = []; + /** @var array */ + private array $filters = []; - /** - * @param int $interval - * @return Query - */ - public function setInterval($interval) + public function setInterval(int $interval): self { $this->interval = $interval; return $this; } - /** - * @param int $startTime - * @return Query - */ - public function setStartTime($startTime) + public function setStartTime(int $startTime): self { $this->startTime = $startTime; return $this; } - /** - * @param int $endTime - * @return Query - */ - public function setEndTime($endTime) + public function setEndTime(int $endTime): self { $this->endTime = $endTime; return $this; } - /** - * @param string $collection - * @return Query - */ - public function setCollection($collection) + public function setCollection(string $collection): self { $this->collection = $collection; return $this; } - /** - * @param string $name - * @param string $expression - * @return Query - */ - public function addField($name, $expression) + public function addField(string $name, string $expression): self { $this->fields[$name] = $expression; return $this; } - /** - * @param string $key - * @param string $value - * @return Query - */ - public function addFilter($key, $value) + public function addFilter(string $key, string $value): self { $this->filters[$key] = $value; return $this; @@ -84,9 +60,9 @@ public function addFilter($key, $value) /** * Returns the query as an array. - * @return array + * @return array */ - public function asArray() + public function asArray(): array { $query = [ 'stream' => [ @@ -108,29 +84,4 @@ public function asArray() } return $query; } - - /** - * @return int - */ - public function getStartTime() - { - return $this->startTime; - } - - /** - * @return int - */ - public function getEndTime() - { - return $this->endTime; - } - - /** - * @return int - */ - public function getInterval() - { - return $this->interval; - } - } diff --git a/src/Model/Metrics/Sketch.php b/src/Model/Metrics/Sketch.php index f63aa7d445..a1e5795891 100644 --- a/src/Model/Metrics/Sketch.php +++ b/src/Model/Metrics/Sketch.php @@ -1,55 +1,47 @@ , info: array} $value + * @return self */ - public static function fromApiValue(array $value) + public static function fromApiValue(array $value): self { - $s = new Sketch(); - $s->name = $value['info']['name']; - $s->count = isset($value['value']['count']) ? $value['value']['count'] : 1; - $s->sum = isset($value['value']['sum']) ? $value['value']['sum'] : 0; - return $s; + return new Sketch( + $value['value']['sum'] ?? null, + $value['value']['count'] ?? 1, + $value['info']['name'], + ); } - /** - * @return string - */ - public function name() + public function name(): string { return $this->name; } - /** - * @return bool - */ - public function isInfinite() + public function isInfinite(): bool { return $this->sum === 'Infinity' || $this->count === 'Infinity'; } - /** - * @return float - */ - public function average() + public function average(): float { if ($this->isInfinite()) { throw new \RuntimeException('Cannot find the average of an infinite value'); } + if ($this->sum === null) { + return 0; + } + if (is_string($this->sum)) { + throw new \RuntimeException('Cannot find the average of a string "sum": ' . $this->sum); + } return $this->sum / (float) $this->count; } } diff --git a/src/Model/Metrics/TimeSpec.php b/src/Model/Metrics/TimeSpec.php index 20168676ff..9e5c97b99c 100644 --- a/src/Model/Metrics/TimeSpec.php +++ b/src/Model/Metrics/TimeSpec.php @@ -1,45 +1,29 @@ startTime = $startTime; - $this->endTime = $endTime; - $this->interval = $interval; - } + public function __construct(private int $startTime, private int $endTime, private int $interval) {} - /** - * @return int - */ - public function getStartTime() + public function getStartTime(): int { return $this->startTime; } - /** - * @return int - */ - public function getEndTime() + public function getEndTime(): int { return $this->endTime; } - /** - * @return int - */ - public function getInterval() + public function getInterval(): int { return $this->interval; } diff --git a/src/Model/ProjectRoles.php b/src/Model/ProjectRoles.php index b4305d8bc2..691c39d5e0 100644 --- a/src/Model/ProjectRoles.php +++ b/src/Model/ProjectRoles.php @@ -1,5 +1,7 @@ webApp = $webApp; - $this->environment = $environment; - } + public function __construct(private WebApp $webApp, private Environment $environment) {} - /** - * {@inheritdoc} - */ - public function getSshUrl($instance = '') + public function getSshUrl($instance = ''): string { return $this->environment->getSshUrl($this->webApp->name, $instance); } - /** - * {@inheritdoc} - */ - public function getName() + public function getName(): string { return $this->webApp->name; } - /** - * {@inheritdoc} - */ - public function getConfig() { + public function getConfig(): AppConfig + { return AppConfig::fromWebApp($this->webApp); } - /** - * {@inheritDoc} - */ - public function getRuntimeOperations() + public function getRuntimeOperations(): array { return $this->webApp->getRuntimeOperations(); } diff --git a/src/Model/RemoteContainer/BrokenEnv.php b/src/Model/RemoteContainer/BrokenEnv.php index 2b73da6695..91346399e8 100644 --- a/src/Model/RemoteContainer/BrokenEnv.php +++ b/src/Model/RemoteContainer/BrokenEnv.php @@ -1,5 +1,7 @@ environment = $environment; - $this->appName = $appName; - } + public function __construct(private Environment $environment, private string $appName) {} /** * {@inheritdoc} */ - public function getSshUrl($instance = '') + public function getSshUrl($instance = ''): string { return $this->environment->getSshUrl($this->appName, $instance); } @@ -36,7 +28,7 @@ public function getSshUrl($instance = '') /** * {@inheritdoc} */ - public function getName() + public function getName(): string { return $this->appName; } @@ -44,14 +36,15 @@ public function getName() /** * {@inheritdoc} */ - public function getConfig() { + public function getConfig(): AppConfig + { return new AppConfig(!empty($this->appName) ? ['name' => $this->appName] : []); } /** * {@inheritDoc} */ - public function getRuntimeOperations() + public function getRuntimeOperations(): array { return []; } diff --git a/src/Model/RemoteContainer/RemoteContainerInterface.php b/src/Model/RemoteContainer/RemoteContainerInterface.php index ad448387f1..f6a518031a 100644 --- a/src/Model/RemoteContainer/RemoteContainerInterface.php +++ b/src/Model/RemoteContainer/RemoteContainerInterface.php @@ -1,7 +1,10 @@ */ - public function getRuntimeOperations(); + public function getRuntimeOperations(): array; } diff --git a/src/Model/RemoteContainer/Worker.php b/src/Model/RemoteContainer/Worker.php index edbb02d1be..6c41d7cc82 100644 --- a/src/Model/RemoteContainer/Worker.php +++ b/src/Model/RemoteContainer/Worker.php @@ -1,52 +1,33 @@ worker = $worker; - $this->environment = $environment; - } + public function __construct(private DeployedWorker $worker, private Environment $environment) {} - /** - * {@inheritdoc} - */ - public function getSshUrl($instance = '') + public function getSshUrl($instance = ''): string { return $this->environment->getSshUrl($this->worker->name, $instance); } - /** - * {@inheritdoc} - */ - public function getName() + public function getName(): string { return $this->worker->name; } - /** - * {@inheritdoc} - */ - public function getConfig() { + public function getConfig(): AppConfig + { return new AppConfig($this->worker->getProperties()); } - /** - * {@inheritDoc} - */ - public function getRuntimeOperations() + public function getRuntimeOperations(): array { return $this->worker->getRuntimeOperations(); } diff --git a/src/Model/Route.php b/src/Model/Route.php index 84cc982ee4..ade5fd7cca 100644 --- a/src/Model/Route.php +++ b/src/Model/Route.php @@ -1,5 +1,7 @@ null, 'primary' => false]); + /** + * @param array $data + */ + public static function fromData(array $data): self + { + return new self($data + ['id' => null, 'primary' => false]); } /** @@ -26,52 +32,52 @@ public static function fromData(array $data) { * * @return string|false */ - public function getUpstreamName() { + public function getUpstreamName(): false|string + { if (!isset($this->data['upstream'])) { return false; } - return explode(':', $this->data['upstream'], 2)[0]; + return explode(':', (string) $this->data['upstream'], 2)[0]; } /** * Translates routes found in $environment->getRoutes() to Route objects. * - * @see \Platformsh\Client\Model\Environment::getRoutes() - * * @param \Platformsh\Client\Model\Deployment\Route[] $routes * - * @return \Platformsh\Cli\Model\Route[] + * @return Route[] + * @see \Platformsh\Client\Model\Environment::getRoutes() */ - public static function fromDeploymentApi(array $routes) + public static function fromDeploymentApi(array $routes): array { $result = []; foreach ($routes as $url => $route) { $properties = $route->getProperties(); $properties['url'] = $url; - $result[] = static::fromData($properties); + $result[] = self::fromData($properties); } - return static::sort($result); + return self::sort($result); } /** * Translates routes found in PLATFORM_ROUTES to Route objects. * - * @param array $routes + * @param array $routes * - * @return \Platformsh\Cli\Model\Route[] + * @return Route[] */ - public static function fromVariables(array $routes) + public static function fromVariables(array $routes): array { $result = []; foreach ($routes as $url => $route) { $route['url'] = $url; - $result[] = static::fromData($route); + $result[] = self::fromData($route); } - return static::sort($result); + return self::sort($result); } /** @@ -81,8 +87,9 @@ public static function fromVariables(array $routes) * * @return Route[] */ - private static function sort(array $routes) { - usort($routes, function (Route $a, Route $b) { + private static function sort(array $routes): array + { + usort($routes, function (Route $a, Route $b): int { $result = 0; if ($a->primary) { $result -= 4; diff --git a/src/Model/Variable.php b/src/Model/Variable.php index 993dada1e5..1d744161ae 100644 --- a/src/Model/Variable.php +++ b/src/Model/Variable.php @@ -1,5 +1,7 @@ validateType($type), $this->validateName($name), $value]; } @@ -35,7 +37,7 @@ public function parse($variable) * * @return string */ - public function validateType($type) + public function validateType(string $type): string { if (!preg_match('#^[a-zA-Z0-9._\-]+$#', $type)) { throw new \InvalidArgumentException(sprintf('Invalid variable type: %s', $type)); @@ -53,7 +55,7 @@ public function validateType($type) * * @return string */ - public function validateName($name) + public function validateName(string $name): string { if (!preg_match('#^[a-zA-Z0-9._:\-|/]+$#', $name)) { throw new \InvalidArgumentException(sprintf('Invalid variable name: %s', $name)); diff --git a/src/Rector/DependencyInjection.php b/src/Rector/DependencyInjection.php new file mode 100644 index 0000000000..fdfa5cbd7b --- /dev/null +++ b/src/Rector/DependencyInjection.php @@ -0,0 +1,98 @@ +getOrCreateMethod($classNode, $methodName); + + foreach ($method->params as $existingParam) { + if ($existingParam->var->name === $propertyName) { + return; + } + } + + if ($constructor) { + $method->params[] = $this->builderFactory->param($propertyName) + ->setType($serviceClass) + ->makeReadonly() + ->makePrivate() + ->getNode(); + } else { + $property = $this->builderFactory->property($propertyName) + ->makePrivate() + ->setType($serviceClass) + ->getNode(); + array_unshift($classNode->stmts, $property); + $method->params[] = $this->builderFactory->param($propertyName) + ->setType($serviceClass) + ->getNode(); + $method->stmts[] = new Expression(new Assign( + new PropertyFetch(new Variable('this'), $propertyName), + new Variable($propertyName), + )); + } + + usort($method->params, fn(Param $a, Param $b): int => $a->var->name <=> $b->var->name); + } + + private function getOrCreateMethod(Class_ $classNode, string $name): ClassMethod + { + if ($existing = $classNode->getMethod($name)) { + return $existing; + } + + $isConstructor = $name === '__construct'; + + $methodBuilder = new Method($name); + $methodBuilder->makePublic(); + if ($name !== '__construct') { + $methodBuilder->setReturnType('void'); + } + + // Because all the commands are child classes, add a parent constructor call. + if ($classNode->extends !== null && $name === '__construct') { + $parentCall = $this->nodeFactory->createStaticCall('parent', '__construct'); + $methodBuilder->addStmt($parentCall); + } + + $classMethod = $methodBuilder->getNode(); + + // Add #[Required] attribute if the method is not the constructor. + if (!$isConstructor) { + $attributeGroup = $this->phpAttributeGroupFactory->createFromClass(Required::class); + $classMethod->attrGroups[] = $attributeGroup; + } + + // Add the method to the class. + $this->classInsertManipulator->addAsFirstMethod($classNode, $classMethod); + + return $classMethod; + } +} diff --git a/src/Rector/InjectCommandServicesRector.php b/src/Rector/InjectCommandServicesRector.php new file mode 100644 index 0000000000..9e0fff3b08 --- /dev/null +++ b/src/Rector/InjectCommandServicesRector.php @@ -0,0 +1,171 @@ + ActivityLoader::class, + 'activity_monitor' => ActivityMonitor::class, + 'api' => Api::class, + 'app_finder' => ApplicationFinder::class, + 'cache' => CacheProvider::class, + 'certifier' => Certifier::class, + 'config' => Config::class, + 'curl_cli' => CurlCli::class, + 'drush' => Drush::class, + 'file_lock' => FileLock::class, + 'fs' => Filesystem::class, + 'git' => Git::class, + 'git_data_api' => GitDataApi::class, + 'identifier' => Identifier::class, + 'local.build' => LocalBuild::class, + 'local.project' => LocalProject::class, + 'local.dependency_installer' => DependencyInstaller::class, + 'mount' => Mount::class, + 'property_formatter' => PropertyFormatter::class, + 'question_helper' => QuestionHelper::class, + 'remote_env_vars' => RemoteEnvVars::class, + 'relationships' => Relationships::class, + 'rsync' => Rsync::class, + 'self_updater' => SelfUpdater::class, + 'shell' => Shell::class, + 'ssh' => Ssh::class, + 'ssh_config' => SshConfig::class, + 'ssh_diagnostics' => SshDiagnostics::class, + 'ssh_key' => SshKey::class, + 'state' => State::class, + 'table' => Table::class, + 'token_config' => TokenConfig::class, + 'url' => Url::class, + ]; + + private const METHOD_TO_SERVICE = [ + 'api' => Api::class, + 'config' => Config::class, + ]; + + public function __construct( + private readonly BetterNodeFinder $betterNodeFinder, + private readonly DependencyInjection $di, + ) {} + + public function getRuleDefinition(): RuleDefinition + { + return new RuleDefinition('Replace $this->getService() with constructor-injected service', [ + new CodeSample('$foo = $this->getService(\'foo\');', '$foo = $this->foo;'), + ]); + } + + public function getNodeTypes(): array + { + return [Class_::class]; + } + + public function refactor(Node $node): ?Node + { + if (!($node instanceof Class_ && str_contains($node->name->name, 'Command'))) { + return null; + } + + // Build a list of classes to inject (property names keyed by class names). + $injections = []; + + /** @var MethodCall $methodCall */ + foreach ($this->betterNodeFinder->findInstancesOf($node, [MethodCall::class]) as $methodCall) { + if (isset(self::METHOD_TO_SERVICE[$methodCall->name->name])) { + $serviceClass = '\\' . self::METHOD_TO_SERVICE[$methodCall->name->name]; + $parts = explode('\\', $serviceClass); + $serviceClassBase = end($parts); + $propertyName = lcfirst($serviceClassBase); + $injections[$serviceClass] = $propertyName; + } + } + + $this->traverseNodesWithCallable($node->stmts, function (NodeAbstract $node) use (&$injections) { + if ($node instanceof Assign) { + if ($node->expr instanceof MethodCall) { + $method = $node->expr; + if ($method->name->name !== 'getService' || $this->getName($method->var) !== 'this') { + return null; + } + $firstArg = $method->getArgs()[0]; + if (!$firstArg->value instanceof String_) { + throw new \RuntimeException("Unexpected arguments found, line " . $node->getStartLine()); + } + $serviceName = $firstArg->value->value; + if (!isset(self::SERVICE_CLASS_NAMES[$serviceName])) { + throw new \RuntimeException("Class not found for service name $serviceName"); + } + $serviceClass = '\\' . self::SERVICE_CLASS_NAMES[$serviceName]; + $parts = explode('\\', $serviceClass); + $serviceClassBase = end($parts); + $propertyName = lcfirst($serviceClassBase); + $injections[$serviceClass] = $propertyName; + // Clear doc comments. + if ($node->getDocComment() !== null) { + $node->setAttribute(AttributeKey::COMMENTS, null); + } + return new Assign($node->var, $this->nodeFactory->createPropertyFetch('this', $propertyName)); + } + } + return null; + }); + + $useConstructor = !str_ends_with($node->name->name, 'Base'); + + natsort($injections); + foreach ($injections as $serviceClass => $propertyName) { + $this->di->addDependencyInjection($node, $propertyName, $serviceClass, $useConstructor); + } + + return $node; + } +} diff --git a/src/Rector/NewServicesRector.php b/src/Rector/NewServicesRector.php new file mode 100644 index 0000000000..f07f0327fe --- /dev/null +++ b/src/Rector/NewServicesRector.php @@ -0,0 +1,119 @@ +transforms = [ + 'addResourcesInitOption' => [ResourcesUtil::class, 'addOption', '_'], + 'validateResourcesInitInput' => [ResourcesUtil::class, 'validateInput', '_'], + 'debug' => [Io::class, 'debug', '_'], + 'isTerminal' => [Io::class, 'isTerminal', '_'], + 'warnAboutDeprecatedOptions' => [Io::class, 'warnAboutDeprecatedOptions', '_'], + 'runOtherCommand' => [SubCommandRunner::class, 'run', '_'], + 'addWaitOptions' => [ActivityMonitor::class, 'addWaitOptions', [new Arg(new MethodCall(new Variable('this'), 'getDefinition'))]], + 'shouldWait' => [ActivityMonitor::class, 'shouldWait', '_'], + 'hasExternalGitHost' => [ProjectSshInfo::class, 'hasExternalGitHost', '_'], + 'getNonInteractiveAuthHelp' => [Login::class, 'getNonInteractiveAuthHelp', '_'], + 'finalizeLogin' => [Login::class, 'finalize', '_'], + 'allServices' => [ResourcesUtil::class, 'allServices', '_'], + 'supportsDisk' => [ResourcesUtil::class, 'supportsDisk', '_'], + 'loadNextDeployment' => [ResourcesUtil::class, 'loadNextDeployment', '_'], + 'filterServices' => [ResourcesUtil::class, 'filterServices', '_'], + 'sizeInfo' => [ResourcesUtil::class, 'sizeInfo', '_'], + 'formatChange' => [ResourcesUtil::class, 'formatChange', '_'], + 'formatCPU' => [ResourcesUtil::class, 'formatCPU', '_'], + ]; + } + + public function getRuleDefinition(): RuleDefinition + { + return new RuleDefinition('Use various new services instead of old CommandBase methods', [ + new CodeSample(<<<'END' + $this->addWaitOptions(); + $this->runSubCommand('foo'); + END, <<<'END' + $this->activityMonitor->addWaitOptions($this->getDefinition()); + $this->subCommandRunnder->run('foo'); + END), + ]); + } + + public function getNodeTypes(): array + { + return [Class_::class]; + } + + public function refactor(Node $node): ?Node + { + if (!($node instanceof Class_ && str_contains($node->name->name, 'Command'))) { + return null; + } + + $changed = false; + $injections = []; + + $this->traverseNodesWithCallable($node, function (NodeAbstract $node) use (&$injections, &$changed) { + if (!$node instanceof MethodCall || !isset($this->transforms[$node->name->name]) || $this->getName($node->var) !== 'this') { + return null; + } + $changed = true; + /** @var string|Arg[] $args */ + [$serviceClassName, $methodName, $args] = $this->transforms[$node->name->name]; + + // Add the injection. + $serviceClass = '\\' . $serviceClassName; + $parts = explode('\\', $serviceClassName); + $serviceClassBase = end($parts); + $propertyName = lcfirst($serviceClassBase); + $injections[$serviceClass] = $propertyName; + + // Replace the method call with a property call. + $node->var = new PropertyFetch(new Variable('this'), $propertyName); + $node->name = new Node\Identifier($methodName); + + if ($args !== '_') { + $node->args = $args; + } + + return $node; + }); + + if (!$changed) { + return null; + } + + $useConstructor = !str_ends_with($node->name->name, 'Base'); + + natsort($injections); + foreach ($injections as $serviceClass => $propertyName) { + $this->di->addDependencyInjection($node, $propertyName, $serviceClass, $useConstructor); + } + + return $node; + } +} diff --git a/src/Rector/UnnecessaryServiceVariablesRector.php b/src/Rector/UnnecessaryServiceVariablesRector.php new file mode 100644 index 0000000000..80d62bdfb3 --- /dev/null +++ b/src/Rector/UnnecessaryServiceVariablesRector.php @@ -0,0 +1,103 @@ +propertyFormatter; + $output->writeln($formatter->format($value)); + END, <<<'END' + $output->writeln($this->propertyFormatter->format($value)); + END), + ]); + } + + public function getNodeTypes(): array + { + return [Class_::class]; + } + + public function refactor(Node $node): ?Node + { + if (!($node instanceof Class_ && str_contains($node->name->name, 'Command'))) { + return null; + } + + $changed = false; + $variableNamesToPropertyFetches = []; + + foreach ($node->stmts as $stmt) { + if ($stmt instanceof Node\Stmt\ClassMethod) { + foreach ($stmt->stmts as $key => $methodStmt) { + if ($methodStmt instanceof Node\Stmt\Expression + && $methodStmt->expr instanceof Assign + && $methodStmt->expr->expr instanceof PropertyFetch + && $this->isService($methodStmt->expr->expr)) { + $variableName = $this->getName($methodStmt->expr->var); + $propertyFetch = $methodStmt->expr->expr; + $changed = true; + $variableNamesToPropertyFetches[$variableName] = $propertyFetch; + unset($stmt->stmts[$key]); + } + } + } + } + + $this->traverseNodesWithCallable($node->stmts, function (Node $node) use ($variableNamesToPropertyFetches) { + if ($node instanceof Node\Expr\MethodCall && isset($variableNamesToPropertyFetches[$this->getName($node->var)]) + && $this->isService($node->var)) { + $node->var = clone $variableNamesToPropertyFetches[$this->getName($node->var)]; + return $node; + } + if ($node instanceof Node\Arg && $node->value instanceof Node\Expr\Variable + && isset($variableNamesToPropertyFetches[$this->getName($node->value)]) + && $this->isService($node->value)) { + $node->value = clone $variableNamesToPropertyFetches[$this->getName($node->value)]; + return $node; + } + return null; + }); + + if (!$changed) { + return null; + } + + return $node; + } + + private function isService(Node $node): bool + { + $type = $this->nodeTypeResolver->getType($node); + if (!$type instanceof ObjectType) { + return false; + } + $className = $type->getClassName(); + if (str_starts_with($className, 'Platformsh\\Cli\\Service\\')) { + return true; + } + if (in_array($className, [LocalProject::class, LocalBuild::class, LocalApplication::class, Certifier::class])) { + return true; + } + + return false; + } +} diff --git a/src/Rector/UseSelectorServiceRector.php b/src/Rector/UseSelectorServiceRector.php new file mode 100644 index 0000000000..5741595a9a --- /dev/null +++ b/src/Rector/UseSelectorServiceRector.php @@ -0,0 +1,174 @@ + 'addProjectOption', + 'addEnvironmentOption' => 'addEnvironmentOption', + 'addAppOption' => 'addAppOption', + 'addOrganizationOptions' => 'addOrganizationOptions', + 'addRemoteContainerOptions' => 'addRemoteContainerOptions', + ]; + + private const SELECTOR_METHODS = [ + 'getProjectRoot' => 'getProjectRoot', + 'getCurrentProject' => 'getCurrentProject', + 'validateOrganizationInput' => 'selectOrganization', + 'selectedProjectIsCurrent' => 'isProjectCurrent', + ]; + + private const SELECTION_METHODS = [ + 'getSelectedProject' => 'getProject', + 'hasSelectedProject' => 'hasProject', + 'getSelectedEnvironment' => 'getEnvironment', + 'hasSelectedEnvironment' => 'hasEnvironment', + ]; + + public function __construct( + private readonly DependencyInjection $di, + ) {} + + public function getRuleDefinition(): RuleDefinition + { + return new RuleDefinition('Use Selector service instead of old CommandBase methods', [ + new CodeSample(<<<'END' + $this->validateInput($input); + $project = $this->getSelectedProject(); + $environment = $this->getSelectedEnvironment(); + $projectRoot = $this->getProjectRoot(); + $currentProject = $this->getCurrentProject(); + END, <<<'END' + $selection = $this->selector->getSelection($input); + $project = $selection->getProject(); + $environment = $this->getSelectedEnvironment(); + $projectRoot = $this->selector->getProjectRoot(); + $currentProject = $this->selector->getCurrentProject(); + END), + ]); + } + + public function getNodeTypes(): array + { + return [Class_::class]; + } + + public function refactor(Node $node): ?Node + { + if (!($node instanceof Class_ && str_contains($node->name->name, 'Command'))) { + return null; + } + + $injectSelector = false; + + $this->traverseNodesWithCallable($node, function (NodeAbstract $node) use (&$injectSelector) { + if (!$node instanceof MethodCall) { + return null; + } + if ($node->name->name === 'validateInput' && $this->getName($node->var) === 'this') { + $injectSelector = true; + $args = [new Node\Arg(new Variable('input'))]; + if (count($node->args) > 1) { + // Add SelectorConfig. + $selectorConfigArgs = []; + if (isset($node->args[1])) { + $envNotRequired = $node->args[1]->value; + + // Translate the envNotRequired positional argument to an envRequired named argument. + $val = null; + if ($envNotRequired instanceof Expr\ConstFetch) { + // envRequired is the default, so only set it if it's disabled. + if ($this->getName($envNotRequired) === 'true') { + $val = new Expr\ConstFetch(new Node\Name('false')); + } + } elseif ($envNotRequired instanceof Expr\BinaryOp\NotIdentical) { + $val = new Expr\BinaryOp\Identical($envNotRequired->left, $envNotRequired->right); + } elseif ($envNotRequired instanceof Expr\BinaryOp\Identical) { + $val = new Expr\BinaryOp\NotIdentical($envNotRequired->left, $envNotRequired->right); + } else { + $val = new Expr\BooleanNot($envNotRequired); + } + + if ($val !== null) { + $selectorConfigArgs[] = new Arg($val, false, false, [], new Node\Identifier('envRequired')); + } + } + if (isset($node->args[2])) { + $selectorConfigArgs[] = new Arg($node->args[2]->value, false, false, [], new Node\Identifier('selectDefaultEnv')); + } + if (isset($node->args[3])) { + $selectorConfigArgs[] = new Arg($node->args[3]->value, false, false, [], new Node\Identifier('detectCurrentEnv')); + } + $args[] = new Arg(new Node\Expr\New_(new Node\Name('\\' . SelectorConfig::class), $selectorConfigArgs)); + } + return new Assign( + new Variable('selection'), + new MethodCall(new PropertyFetch(new Variable('this'), 'selector'), 'getSelection', $args), + ); + } + if (isset(self::DEFINITION_METHODS[$node->name->name]) && ($node->var instanceof MethodCall || $this->getName($node->var) === 'this')) { + $injectSelector = true; + $hasGetDefinitionArg = function (array $args): bool { + /** @var Arg $arg */ + foreach ($args as $arg) { + if ($arg->value instanceof MethodCall && $arg->value->name->name === 'getDefinition') { + return true; + } + } + return false; + }; + if ($node->var instanceof MethodCall) { + $node->var = new MethodCall(new PropertyFetch(new Variable('this'), 'selector'), $node->var->name->name); + if (!$hasGetDefinitionArg($node->var->args)) { + $node->var->args = array_merge([new Arg(new MethodCall(new Variable('this'), 'getDefinition'))], $node->var->args); + } + $node->name = new Node\Identifier(self::DEFINITION_METHODS[$node->name->name]); + if (!$hasGetDefinitionArg($node->args)) { + $node->args = array_merge([new Arg(new MethodCall(new Variable('this'), 'getDefinition'))], $node->args); + } + return $node; + } + return new MethodCall(new PropertyFetch(new Variable('this'), 'selector'), self::DEFINITION_METHODS[$node->name->name], array_merge([ + new Arg(new MethodCall(new Variable('this'), 'getDefinition')), + ], $node->args)); + } + if ($this->getName($node->var) === 'this') { + if (isset(self::SELECTION_METHODS[$node->name->name])) { + $injectSelector = true; + return new MethodCall(new Variable('selection'), self::SELECTION_METHODS[$node->name->name]); + } + if (isset(self::SELECTOR_METHODS[$node->name->name])) { + $injectSelector = true; + return new MethodCall(new PropertyFetch(new Variable('this'), 'selector'), self::SELECTOR_METHODS[$node->name->name], $node->args); + } + } + return null; + }); + + if ($injectSelector) { + $useConstructor = !str_ends_with($node->name->name, 'Base'); + $this->di->addDependencyInjection($node, 'selector', '\\' . Selector::class, $useConstructor); + return $node; + } + + return null; + } +} diff --git a/src/Selector/Selection.php b/src/Selector/Selection.php new file mode 100644 index 0000000000..cc069a43b4 --- /dev/null +++ b/src/Selector/Selection.php @@ -0,0 +1,107 @@ +config = $config ?: new SelectorConfig(); + } + + /** + * Check whether a project is selected. + * + * @return bool + */ + public function hasProject(): bool + { + return !empty($this->project); + } + + /** + * Get the project selected by the user. + * + * The project is selected via validateInput(), if there is a --project + * option in the command. + * + * @throws \BadMethodCallException + * + * @return Project + */ + public function getProject(): Project + { + if (!$this->project) { + throw new \BadMethodCallException('No project selected'); + } + + return $this->project; + } + + /** + * Check whether a single environment is selected. + * + * @return bool + */ + public function hasEnvironment(): bool + { + return !empty($this->environment); + } + + /** + * Get the environment selected by the user. + * + * The project is selected via validateInput(), if there is an + * --environment option in the command. + * + * @return Environment + */ + public function getEnvironment(): Environment + { + if (!$this->environment) { + throw new \BadMethodCallException('No environment selected'); + } + + return $this->environment; + } + + /** + * Get the app selected by the user. + * + * @return string|null + */ + public function getAppName(): ?string + { + if ($this->appName === null && $this->remoteContainer instanceof App) { + $this->appName = $this->remoteContainer->getName(); + } + + return $this->appName; + } + + /** + * Get the remote container selected by the user. + */ + public function getRemoteContainer(): RemoteContainerInterface + { + if (!$this->remoteContainer) { + throw new \BadMethodCallException('No container selected'); + } + + return $this->remoteContainer; + } +} diff --git a/src/Selector/Selector.php b/src/Selector/Selector.php new file mode 100644 index 0000000000..97cd7e7de1 --- /dev/null +++ b/src/Selector/Selector.php @@ -0,0 +1,1124 @@ +stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; + } + + /** + * Prints a message if debug output is enabled. + * + * @todo refactor this + * + * @param string $message + */ + private function debug(string $message): void + { + $this->stdErr->writeln('DEBUG ' . $message, OutputInterface::VERBOSITY_DEBUG); + } + + /** + * Processes and validates input to select the project, environment and app. + * + * @param InputInterface $input + * @param ?SelectorConfig $config + * + * @return Selection + */ + public function getSelection(InputInterface $input, ?SelectorConfig $config = null): Selection + { + $config = $config ?: new SelectorConfig(); + + // Determine whether the localhost can be used. + $envPrefix = $this->config->getStr('service.env_prefix'); + $allowLocalHost = $config->allowLocalHost && !LocalHost::conflictsWithCommandLineOptions($input, $envPrefix); + + // If the user is not logged in, then return an empty selection. + if ($allowLocalHost && !$config->requireApiOnLocal && !$this->api->isLoggedIn()) { + return new Selection($config); + } + + $projectId = $input->hasOption('project') ? $input->getOption('project') : null; + $projectHost = $input->hasOption('host') ? $input->getOption('host') : null; + $environmentId = null; + + // Identify the project. + if ($projectId !== null) { + $result = $this->identifier->identify($projectId); + $projectId = $result['projectId']; + $projectHost = $projectHost ?: $result['host']; + $environmentId = $result['environmentId']; + } + + // Load the project ID from an environment variable, if available. + if ($projectId === null && getenv($envPrefix . 'PROJECT')) { + $projectId = getenv($envPrefix . 'PROJECT'); + $this->stdErr->writeln(sprintf( + 'Project ID read from environment variable %s: %s', + $envPrefix . 'PROJECT', + $projectId, + ), OutputInterface::VERBOSITY_VERBOSE); + } + + // Select the project. + $project = $this->selectProject($input, $config, $projectId, $projectHost); + + // Select the environment. + $envOptionName = 'environment'; + $environment = null; + + $envArgName = $config->envArgName; + if ($input->hasArgument($envArgName) + && $input->getArgument($envArgName) !== null + && $input->getArgument($envArgName) !== []) { + if ($input->hasOption($envOptionName) && $input->getOption($envOptionName)) { + throw new InvalidArgumentException( + sprintf( + 'You cannot use both the <%s> argument and the --%s option', + $envArgName, + $envOptionName, + ), + ); + } + $argument = $input->getArgument($envArgName); + if (is_array($argument) && count($argument) == 1) { + $argument = $argument[0]; + } + if (!is_array($argument)) { + $this->debug('Selecting environment based on input argument'); + $environment = $this->selectEnvironment($input, $project, $config, $argument); + } + } elseif ($input->hasOption($envOptionName)) { + if ($input->getOption($envOptionName) !== null) { + $environmentId = $input->getOption($envOptionName); + } + $environment = $this->selectEnvironment($input, $project, $config, $environmentId); + } + + // Select the app. + $appName = null; + $remoteContainer = null; + if ($input->hasOption('app')) { + if ($input->getOption('app')) { + $appName = (string) $input->getOption('app'); + } elseif (isset($result['appId'])) { + // An app ID might be provided from the parsed project URL. + $appName = $result['appId']; + $this->debug(sprintf( + 'App name identified as: %s', + $appName, + )); + } elseif (getenv($envPrefix . 'APPLICATION_NAME')) { + // Or from an environment variable. + $appName = (string) getenv($envPrefix . 'APPLICATION_NAME'); + $this->stdErr->writeln(sprintf( + 'App name read from environment variable %s: %s', + $envPrefix . 'APPLICATION_NAME', + $appName, + ), OutputInterface::VERBOSITY_VERBOSE); + } + + $remoteContainer = $this->selectRemoteContainer($environment, $input, $appName); + } + + $selection = new Selection($config, $project, $environment, $appName, $remoteContainer); + if ($this->stdErr->isVerbose()) { + $this->ensurePrintedSelection($selection); + } + + return $selection; + } + + /** + * Ensures the selection is printed. + * + * @param bool $blankLine Append an extra newline after the message, if any is printed. + */ + public function ensurePrintedSelection(Selection $selection, bool $blankLine = false): void + { + $outputAnything = false; + if ($selection->hasProject() && $this->printedProject !== $selection->getProject()->id) { + $this->stdErr->writeln('Selected project: ' . $this->api->getProjectLabel($selection->getProject())); + $outputAnything = true; + $this->printedProject = $selection->getProject()->id; + } + if ($selection->hasEnvironment() && $this->printedEnvironment !== $selection->getEnvironment()->id) { + $this->stdErr->writeln('Selected environment: ' . $this->api->getEnvironmentLabel($selection->getEnvironment())); + $outputAnything = true; + $this->printedEnvironment = $selection->getEnvironment()->id; + } + if ($blankLine && $outputAnything) { + $this->stdErr->writeln(''); + } + } + + public function getHostFromSelection(InputInterface $input, Selection $selection): HostInterface + { + $envPrefix = $this->config->getStr('service.env_prefix'); + $allowLocalHost = $selection->config->allowLocalHost && !LocalHost::conflictsWithCommandLineOptions($input, $envPrefix); + if ($allowLocalHost) { + $this->debug('Selected host: localhost'); + return $this->hostFactory->local(); + } + + $remoteContainer = $selection->getRemoteContainer(); + $instanceId = $input->hasOption('instance') ? $input->getOption('instance') : null; + if ($input->hasOption('instance') && $instanceId !== null) { + $instances = $selection->getEnvironment()->getSshInstanceURLs($remoteContainer->getName()); + if ((!empty($instances) || $instanceId !== '0') && !isset($instances[$instanceId])) { + throw new ConsoleInvalidArgumentException("Instance not found: $instanceId. Available instances: " . implode(', ', array_keys($instances))); + } + } + + $sshUrl = $remoteContainer->getSshUrl($instanceId); + $this->debug('Selected host: ' . $sshUrl); + return $this->hostFactory->remote($sshUrl, $selection->getEnvironment()); + } + + public function isProjectCurrent(Project $project): bool + { + $current = $this->getCurrentProject(true); + + return $current && $current->id === $project->id; + } + + /** + * Selects the project for the user, based on input or the environment. + */ + private function selectProject(InputInterface $input, SelectorConfig $config, ?string $projectId = null, ?string $host = null): Project + { + if (!empty($projectId)) { + $project = $this->api->getProject($projectId, $host); + if (!$project) { + throw new InvalidArgumentException($this->getProjectNotFoundMessage($projectId)); + } + + $this->debug('Selected project: ' . $project->id); + + return $project; + } + + if ($config->detectCurrentEnv) { + $currentProject = $this->getCurrentProject(); + if ($currentProject) { + return $currentProject; + } + } + + if ($input->isInteractive()) { + $myProjects = $this->api->getMyProjects(); + if (count($myProjects) > 0) { + $this->debug('No project specified: offering a choice...'); + $projectId = $this->offerProjectChoice($myProjects, $config); + $project = $this->api->getProject($projectId); + if (!$project) { + throw new \RuntimeException('Failed to load project: ' . $projectId); + } + + return $project; + } + } + + if ($config->detectCurrentEnv) { + throw new RootNotFoundException( + "Could not determine the current project." + . "\n\nSpecify it using --project, or go to a project directory.", + ); + } + + throw new InvalidArgumentException('You must specify a project.'); + } + + /** + * Format an error message about a not-found project. + * + * @param string $projectId + * + * @return string + */ + private function getProjectNotFoundMessage(string $projectId): string + { + $message = 'Specified project not found: ' . $projectId; + if ($projectInfos = $this->api->getMyProjects()) { + $message .= "\n\nYour projects are:"; + $limit = 8; + foreach (array_slice($projectInfos, 0, $limit) as $info) { + $message .= "\n " . $info->id; + if ($info->title !== '') { + $message .= ' - ' . $info->title; + } + } + if (count($projectInfos) > $limit) { + $message .= "\n ..."; + $message .= "\n\n List projects with: " . $this->config->getStr('application.executable') . ' projects'; + } + } + + return $message; + } + + /** + * Select the current environment for the user. + * + * @param InputInterface $input + * @param Project $project + * The project, or null if no project is selected. + * @param SelectorConfig $config + * @param string|null $environmentId + * The environment ID specified by the user, or null to auto-detect the + * environment. + * + * @return Environment|null + * @throws \Exception + */ + private function selectEnvironment(InputInterface $input, Project $project, SelectorConfig $config, ?string $environmentId = null): ?Environment + { + $envPrefix = $this->config->getStr('service.env_prefix'); + if ($environmentId === null && getenv($envPrefix . 'BRANCH')) { + $environmentId = getenv($envPrefix . 'BRANCH'); + $this->stdErr->writeln(sprintf( + 'Environment ID read from environment variable %s: %s', + $envPrefix . 'BRANCH', + $environmentId, + ), OutputInterface::VERBOSITY_VERBOSE); + } + + if ($environmentId !== null) { + if ($environmentId === self::DEFAULT_ENVIRONMENT_CODE) { + $this->stdErr->writeln(sprintf('Selecting default environment (indicated by %s)', $environmentId)); + $environments = $this->api->getEnvironments($project); + $environment = $this->api->getDefaultEnvironment($environments, $project, true); + if (!$environment) { + throw new \RuntimeException('Default environment not found'); + } + $this->stdErr->writeln(\sprintf('Selected environment: %s', $this->api->getEnvironmentLabel($environment))); + $this->printedEnvironment = $environmentId; + return $environment; + } + + $environment = $this->api->getEnvironment($environmentId, $project, null, true); + if (!$environment) { + throw new InvalidArgumentException('Specified environment not found: ' . $environmentId); + } + + return $environment; + } + + if ($config->detectCurrentEnv && ($environment = $this->getCurrentEnvironment($project))) { + return $environment; + } + + if ($config->selectDefaultEnv) { + $this->debug('No environment specified or detected: finding a default...'); + $environments = $this->api->getEnvironments($project); + $environment = $this->api->getDefaultEnvironment($environments, $project); + if ($environment) { + $this->stdErr->writeln(\sprintf('Selected default environment: %s', $this->api->getEnvironmentLabel($environment))); + $this->printedEnvironment = $environment->id; + return $environment; + } + } + + if ($config->envRequired && $input->isInteractive()) { + $environments = $this->api->getEnvironments($project); + if ($config->chooseEnvFilter !== null) { + $environments = array_filter($environments, $config->chooseEnvFilter); + } + if (count($environments) === 1) { + $only = reset($environments); + $this->stdErr->writeln(\sprintf('Selected environment: %s (by default)', $this->api->getEnvironmentLabel($only))); + $this->printedEnvironment = $only->id; + return $only; + } + if (count($environments) > 0) { + $this->debug('No environment specified or detected: offering a choice...'); + return $this->offerEnvironmentChoice($input, $project, $config, $environments); + } + throw new InvalidArgumentException('Could not select an environment automatically.' + . "\n" . 'Specify one manually using --environment (-e).'); + } + + if ($config->envRequired) { + if ($this->getProjectRoot() || !$config->detectCurrentEnv) { + $message = 'Could not determine the current environment.' + . "\n" . 'Specify it manually using --environment (-e).'; + } else { + $message = 'No environment specified.' + . "\n" . 'Specify one using --environment (-e), or go to a project directory.'; + } + throw new InvalidArgumentException($message); + } + + return null; + } + + /** + * Offer the user an interactive choice of projects. + * + * @param BasicProjectInfo[] $projectInfos + * @param SelectorConfig $config + * + * @return string + * The chosen project ID. + */ + private function offerProjectChoice(array $projectInfos, SelectorConfig $config): string + { + if (count($projectInfos) >= 25 || count($projectInfos) > (new Terminal())->getHeight() - 3) { + $autocomplete = []; + foreach ($projectInfos as $info) { + if ($info->title) { + $autocomplete[$info->id] = $info->id . ' - ' . $info->title . ''; + } else { + $autocomplete[$info->id] = $info->id; + } + } + asort($autocomplete, SORT_NATURAL | SORT_FLAG_CASE); + return $this->questionHelper->askInput($config->enterProjectText, null, array_values($autocomplete), function ($value) use ($autocomplete): string { + [$id, ] = explode(' - ', $value); + if (empty(trim($id))) { + throw new InvalidArgumentException('A project ID is required'); + } + if (!isset($autocomplete[$id]) && !$this->api->getProject($id)) { + throw new InvalidArgumentException('Project not found: ' . $id); + } + return $id; + }); + } + + $projectList = []; + foreach ($projectInfos as $info) { + $projectList[$info->id] = $this->api->getProjectLabel($info, false); + } + asort($projectList, SORT_NATURAL | SORT_FLAG_CASE); + + return $this->questionHelper->choose($projectList, $config->chooseProjectText, null, false); + } + + /** + * Offers a choice of environments. + * + * @param InputInterface $input + * @param Project $project + * @param SelectorConfig $config + * @param Environment[] $environments + * @return Environment + */ + private function offerEnvironmentChoice(InputInterface $input, Project $project, SelectorConfig $config, array $environments): Environment + { + if (!$input->isInteractive()) { + throw new \BadMethodCallException('Not interactive: an environment choice cannot be offered.'); + } + + $defaultEnvironmentId = $this->api->getDefaultEnvironment($environments, $project)?->id; + + if (count($environments) > (new Terminal())->getHeight() / 2) { + $ids = array_keys($environments); + sort($ids, SORT_NATURAL | SORT_FLAG_CASE); + + $id = $this->questionHelper->askInput($config->enterEnvText, $defaultEnvironmentId, array_keys($environments), function (string $value) use ($environments): string { + if (!isset($environments[$value])) { + throw new \RuntimeException('Environment not found: ' . $value); + } + + return $value; + }); + } else { + $environmentList = []; + foreach ($environments as $environment) { + $environmentList[$environment->id] = $this->api->getEnvironmentLabel($environment, false); + } + asort($environmentList, SORT_NATURAL | SORT_FLAG_CASE); + + $text = $config->chooseEnvText; + if ($defaultEnvironmentId) { + $text .= "\n" . 'Default: ' . $defaultEnvironmentId . ''; + } + + $id = $this->questionHelper->choose($environmentList, $text, $defaultEnvironmentId, false); + } + + return $environments[$id]; + } + + /** + * Get the current project if the user is in a project directory. + * + * @param bool $suppressErrors Suppress 403 or not found errors. + * + * @return Project|false The current project + * + * @throws \RuntimeException + */ + public function getCurrentProject(bool $suppressErrors = false): Project|false + { + if (isset($this->currentProject)) { + return $this->currentProject; + } + if (!$projectRoot = $this->getProjectRoot()) { + return false; + } + + $project = false; + $config = $this->localProject->getProjectConfig($projectRoot); + if ($config) { + $this->debug('Project "' . $config['id'] . '" is mapped to the current directory'); + try { + $project = $this->api->getProject($config['id'], $config['host'] ?? null); + } catch (BadResponseException $e) { + if ($suppressErrors && in_array($e->getResponse()->getStatusCode(), [403, 404])) { + return $this->currentProject = false; + } + if ($this->config->has('api.base_url') + && $e->getResponse()->getStatusCode() === 401 + && parse_url($this->config->getStr('api.base_url'), PHP_URL_HOST) !== $e->getRequest()->getUri()->getHost()) { + $this->debug('Ignoring 401 error for unrecognized local project hostname: ' . $e->getRequest()->getUri()->getHost()); + return $this->currentProject = false; + } + throw $e; + } + if (!$project) { + if ($suppressErrors) { + return $this->currentProject = false; + } + throw new ProjectNotFoundException( + "Project not found: " . $config['id'] + . "\nEither you do not have access to the project or it no longer exists.", + ); + } + } + $this->currentProject = $project; + + return $project; + } + + /** + * Get the current environment if the user is in a project directory. + * + * @param Project|null $expectedProject The expected project. + * @param bool|null $refresh Whether to refresh the environments or projects + * cache. + * + * @throws \Exception + * @return Environment|false The current environment. + */ + public function getCurrentEnvironment(?Project $expectedProject = null, ?bool $refresh = null): Environment|false + { + if (!($projectRoot = $this->getProjectRoot()) + || !($project = $this->getCurrentProject(true)) + || ($expectedProject !== null && $expectedProject->id !== $project->id)) { + return false; + } + + $this->git->setDefaultRepositoryDir($projectRoot); + $config = $this->localProject->getProjectConfig($projectRoot); + + // Check if there is a manual mapping set for the current branch. + if (!empty($config['mapping']) + && ($currentBranch = $this->git->getCurrentBranch()) + && !empty($config['mapping'][$currentBranch])) { + $environment = $this->api->getEnvironment($config['mapping'][$currentBranch], $project, $refresh); + if ($environment) { + $this->debug('Found mapped environment for branch ' . $currentBranch . ': ' . $this->api->getEnvironmentLabel($environment)); + return $environment; + } else { + unset($config['mapping'][$currentBranch]); + $this->localProject->writeCurrentProjectConfig($config, $projectRoot); + } + } + + // Check whether the user has a Git upstream set to a remote environment + // ID. + $upstream = $this->git->getUpstream(); + if ($upstream && str_contains($upstream, '/')) { + [, $potentialEnvironment] = explode('/', $upstream, 2); + $environment = $this->api->getEnvironment($potentialEnvironment, $project, $refresh); + if ($environment) { + $this->debug('Selected environment ' . $this->api->getEnvironmentLabel($environment) . ', based on Git upstream: ' . $upstream); + return $environment; + } + } + + // There is no Git remote set. Fall back to trying the current branch + // name. + if (!empty($currentBranch) || ($currentBranch = $this->git->getCurrentBranch())) { + $environment = $this->api->getEnvironment($currentBranch, $project, $refresh); + if (!$environment) { + // Try a sanitized version of the branch name too. + $currentBranchSanitized = Environment::sanitizeId($currentBranch); + if ($currentBranchSanitized !== $currentBranch) { + $environment = $this->api->getEnvironment($currentBranchSanitized, $project, $refresh); + } + } + if ($environment) { + $this->debug('Selected environment ' . $this->api->getEnvironmentLabel($environment) . ' based on branch name: ' . $currentBranch); + return $environment; + } + } + + return false; + } + + /** + * @return string|false + */ + public function getProjectRoot(): string|false + { + if (!isset($this->projectRoot)) { + $this->projectRoot = $this->localProject->getProjectRoot(); + } + + return $this->projectRoot; + } + + /** + * Add the --project and --host options. + * + * @param InputDefinition $inputDefinition + */ + public function addProjectOption(InputDefinition $inputDefinition): void + { + $inputDefinition->addOption(new InputOption('project', 'p', InputOption::VALUE_REQUIRED, 'The project ID or URL')); + $inputDefinition->addOption(new HiddenInputOption('host', null, InputOption::VALUE_REQUIRED, 'Deprecated option, no longer used')); + } + + /** + * Add the --environment option. + * + * @param InputDefinition $inputDefinition + */ + public function addEnvironmentOption(InputDefinition $inputDefinition): void + { + $inputDefinition->addOption(new InputOption('environment', 'e', InputOption::VALUE_REQUIRED, 'The environment ID. Use "' . self::DEFAULT_ENVIRONMENT_CODE . '" to select the project\'s default environment.')); + } + + /** + * Add the --app option. + * + * @param InputDefinition $inputDefinition + */ + public function addAppOption(InputDefinition $inputDefinition): void + { + $inputDefinition->addOption(new InputOption('app', 'A', InputOption::VALUE_REQUIRED, 'The remote application name')); + } + + /** + * Add the --app and --worker and --instance options. + */ + public function addRemoteContainerOptions(InputDefinition $definition): static + { + if (!$definition->hasOption('app')) { + $this->addAppOption($definition); + } + if (!$definition->hasOption('worker')) { + $definition->addOption(new InputOption('worker', null, InputOption::VALUE_REQUIRED, 'A worker name')); + } + $definition->addOption(new InputOption('instance', 'I', InputOption::VALUE_REQUIRED, 'An instance ID')); + return $this; + } + + /** + * Find what app or worker container the user wants to select. + * + * Needs the --app and --worker options, as applicable. + * + * @param Environment $environment + * @param InputInterface $input + * The user input object. + * @param string|null $appName + * + * @return RemoteContainerInterface + * A class representing a container that allows SSH access. + */ + private function selectRemoteContainer(Environment $environment, InputInterface $input, ?string $appName): RemoteContainerInterface + { + $includeWorkers = $input->hasOption('worker'); + try { + $deployment = $this->api->getCurrentDeployment( + $environment, + $input->hasOption('refresh') && $input->getOption('refresh'), + ); + } catch (EnvironmentStateException $e) { + if ($environment->isActive() && $e->getMessage() === 'Current deployment not found') { + $appName = $input->hasOption('app') ? $input->getOption('app') : ''; + + return new BrokenEnv($environment, $appName); + } + throw $e; + } + + // Validate the --app option, without doing anything with it. + if ($appName === null) { + $appName = $input->hasOption('app') ? $input->getOption('app') : null; + } + + // Handle the --worker option first, as it's more specific. + $workerOption = $includeWorkers && $input->hasOption('worker') ? $input->getOption('worker') : null; + if ($workerOption !== null) { + // Check for a conflict with the --app option. + if ($appName !== null + && str_contains((string) $workerOption, '--') + && stripos((string) $workerOption, $appName . '--') !== 0) { + throw new InvalidArgumentException(sprintf( + 'App name "%s" conflicts with worker name "%s"', + $appName, + $workerOption, + )); + } + + // If we have the app name, load the worker directly. + if (str_contains((string) $workerOption, '--') || $appName !== null) { + $qualifiedWorkerName = str_contains((string) $workerOption, '--') + ? $workerOption + : $appName . '--' . $workerOption; + try { + $worker = $deployment->getWorker($qualifiedWorkerName); + } catch (\InvalidArgumentException) { + throw new InvalidArgumentException('Worker not found: ' . $workerOption . ' (in app: ' . $appName . ')'); + } + $this->stdErr->writeln(sprintf('Selected worker: %s', $worker->name), OutputInterface::VERBOSITY_VERBOSE); + + return new Worker($worker, $environment); + } + + // If we don't have the app name, find all the possible matching + // workers, and ask the user to choose. + $suffix = '--' . $workerOption; + $suffixLength = strlen($suffix); + $workerNames = []; + foreach ($deployment->workers as $worker) { + if (substr($worker->name, -$suffixLength) === $suffix) { + $workerNames[] = $worker->name; + } + } + if (count($workerNames) === 0) { + throw new InvalidArgumentException('Worker not found: ' . $workerOption); + } + if (count($workerNames) === 1) { + $workerName = reset($workerNames); + $this->stdErr->writeln(sprintf('Selected worker: %s', $workerName), OutputInterface::VERBOSITY_VERBOSE); + + return new Worker($deployment->getWorker($workerName), $environment); + } + if (!$input->isInteractive()) { + throw new InvalidArgumentException(sprintf( + 'Ambiguous worker name: %s (matches: %s)', + $workerOption, + implode(', ', $workerNames), + )); + } + $workerName = $this->questionHelper->choose( + array_combine($workerNames, $workerNames), + 'Enter a number to choose a worker:', + ); + $this->stdErr->writeln(sprintf('Selected worker: %s', $workerName), OutputInterface::VERBOSITY_VERBOSE); + + return new Worker($deployment->getWorker($workerName), $environment); + } + // Prompt the user to choose between the app(s) or worker(s) that have + // been found. + $appNames = $appName !== null + ? [$appName] + : array_map(fn(WebApp $app) => $app->name, $deployment->webapps); + $choices = array_combine($appNames, $appNames); + $choicesIncludeWorkers = false; + if ($includeWorkers) { + $servicesWithSsh = []; + foreach ($environment->getSshUrls() as $key => $sshUrl) { + $parts = explode(':', $key, 2); + $servicesWithSsh[$parts[0]] = $sshUrl; + } + foreach ($deployment->workers as $worker) { + if (!isset($servicesWithSsh[$worker->name])) { + // Only include workers in the interactive selection if they + // have SSH endpoints. Some Dedicated environments do not have + // separate SSH endpoints for workers. + continue; + } + [$appPart, ] = explode('--', $worker->name, 2); + if (in_array($appPart, $appNames, true)) { + $choices[$worker->name] = $worker->name; + $choicesIncludeWorkers = true; + } + } + } + if (count($choices) === 0) { + throw new \RuntimeException('Failed to find apps or workers for environment: ' . $environment->id); + } + if (count($appNames) === 1) { + $choice = reset($appNames); + } elseif ($input->isInteractive()) { + if ($choicesIncludeWorkers) { + $text = sprintf( + 'Enter a number to choose an app or %s worker:', + count($choices) === 2 ? 'its' : 'a', + ); + } else { + $text = 'Enter a number to choose an app:'; + } + ksort($choices, SORT_NATURAL); + $choice = $this->questionHelper->choose($choices, $text); + } else { + throw new InvalidArgumentException( + $includeWorkers + ? 'Specifying the --app or --worker is required in non-interactive mode' + : 'Specifying the --app is required in non-interactive mode', + ); + } + + // Match the choice to a worker or app destination. + if (str_contains((string) $choice, '--')) { + $this->stdErr->writeln(sprintf('Selected worker: %s', $choice), OutputInterface::VERBOSITY_VERBOSE); + return new Worker($deployment->getWorker($choice), $environment); + } + + $this->stdErr->writeln(sprintf('Selected app: %s', $choice), OutputInterface::VERBOSITY_VERBOSE); + + return new App($deployment->getWebApp($choice), $environment); + } + + /** + * Adds the --org (-o) organization name option. + * + * @param InputDefinition $definition + * @param bool $includeProjectOption + * Adds a --project option which means the organization may be + * auto-selected based on the current or specified project. + */ + public function addOrganizationOptions(InputDefinition $definition, bool $includeProjectOption = false): void + { + if ($this->config->getBool('api.organizations')) { + $definition->addOption(new InputOption('org', 'o', InputOption::VALUE_REQUIRED, 'The organization name (or ID)')); + if ($includeProjectOption && !$definition->hasOption('project')) { + $definition->addOption(new InputOption('project', 'p', InputOption::VALUE_REQUIRED, 'The project ID or URL, which auto-selects the organization if --org is not used')); + } + } + } + + /** + * Returns the selected organization according to the --org option. + * + * @param InputInterface $input + * @param string $filterByLink + * If no organization is specified, this filters the list of the organizations presented by the name of a HAL + * link. For example, 'create-subscription' will list organizations under which the user has the permission to + * create a subscription. + * @param string $filterByCapability + * If no organization is specified, this filters the list of the organizations presented to those with the given + * capability. + * @param bool $skipCache + * + * @return Organization + * @throws NoOrganizationsException if the user does not have any organizations matching the filter + * @throws \InvalidArgumentException if no organization is specified + *@see Selector::addOrganizationOptions() + * + * @todo include this in getSelection according to config + */ + public function selectOrganization(InputInterface $input, string $filterByLink = '', string $filterByCapability = '', bool $skipCache = false): Organization + { + if (!$this->config->getBool('api.organizations')) { + throw new \BadMethodCallException('Organizations are not enabled'); + } + + $explicitProject = $input->hasOption('project') && $input->getOption('project'); + $selection = $explicitProject ? $this->getSelection($input) : new Selection(); + + if ($identifier = $input->getOption('org')) { + // Organization names have to be lower case, while organization IDs are the uppercase ULID format. + // So it's easy to distinguish one from the other. + /** @link https://github.com/ulid/spec */ + if (\preg_match('#^[0-9A-HJKMNP-TV-Z]{26}$#', (string) $identifier) === 1) { + $this->debug('Detected organization ID format (ULID): ' . $identifier); + $organization = $this->api->getOrganizationById($identifier, $skipCache); + } else { + $organization = $this->api->getOrganizationByName($identifier, $skipCache); + } + if (!$organization) { + throw new InvalidArgumentException('Organization not found: ' . $identifier); + } + + // Check for a conflict between the --org and the --project options. + if ($explicitProject && $selection->hasProject()) { + $project = $selection->getProject(); + if ($project->getProperty('organization', true, false) !== $organization->id) { + throw new InvalidArgumentException("The project $project->id is not part of the organization $organization->id"); + } + } + + return $organization; + } + + if ($explicitProject) { + $organization = $this->api->getOrganizationById($selection->getProject()->getProperty('organization'), $skipCache); + if ($organization) { + $this->stdErr->writeln(\sprintf('Project organization: %s', $this->api->getOrganizationLabel($organization))); + return $organization; + } + } elseif (($currentProject = $this->getCurrentProject(true)) && $currentProject->hasProperty('organization')) { + $organizationId = $currentProject->getProperty('organization'); + try { + $organization = $this->api->getOrganizationById($organizationId, $skipCache); + } catch (BadResponseException $e) { + $this->debug('Error when fetching project organization: ' . $e->getMessage()); + $organization = false; + } + if ($organization) { + if ($filterByLink === '' || $organization->hasLink($filterByLink)) { + if ($this->stdErr->isVerbose()) { + $this->ensurePrintedSelection(new Selection(project: $currentProject)); + $this->stdErr->writeln(\sprintf('Project organization: %s', $this->api->getOrganizationLabel($organization))); + } + return $organization; + } elseif ($this->stdErr->isVerbose()) { + $this->stdErr->writeln(sprintf( + 'Not auto-selecting project organization %s (it does not have the link %s)', + $this->api->getOrganizationLabel($organization, 'comment'), + $filterByLink, + )); + } + } + } + + $userId = $this->api->getMyUserId(); + $organizations = $this->api->getClient()->listOrganizationsWithMember($userId); + + if (!$input->isInteractive()) { + throw new \InvalidArgumentException('An organization name or ID (--org) is required.'); + } + if (!$organizations) { + throw new NoOrganizationsException('No organizations found.', 0); + } + + $this->api->sortResources($organizations, 'name'); + $options = []; + $byId = []; + $owned = []; + foreach ($organizations as $organization) { + if ($filterByLink !== '' && !$organization->hasLink($filterByLink)) { + continue; + } + if ($filterByCapability !== '' && !in_array($filterByCapability, $organization->capabilities, true)) { + continue; + } + $options[$organization->id] = $this->api->getOrganizationLabel($organization, false); + $byId[$organization->id] = $organization; + if ($organization->owner_id === $userId) { + $owned[$organization->id] = $organization; + } + } + if (empty($options)) { + $message = 'No organizations found.'; + $filters = []; + if ($filterByLink !== '') { + $filters[] = sprintf('access to the link "%s"', $filterByLink); + } + if ($filterByCapability !== '') { + $filters[] = sprintf('capability "%s"', $filterByCapability); + } + if ($filters) { + $message = sprintf('No organizations found (filtered by %s).', implode(' and ', $filters)); + } + throw new NoOrganizationsException($message, count($organizations)); + } + if (count($byId) === 1) { + /** @var Organization $organization */ + $organization = reset($byId); + $this->stdErr->writeln(\sprintf('Selected organization: %s (by default)', $this->api->getOrganizationLabel($organization))); + return $organization; + } + $default = null; + if (count($owned) === 1) { + $default = (string) key($owned); + + // Move the default to the top of the list and label it. + $options = [$default => $options[$default] . ' (default)'] + $options; + } + + $id = $this->questionHelper->choose($options, 'Enter a number to choose an organization (-o):', $default); + return $byId[$id]; + } + + /** + * Runs autocompletion for Selector options. + */ + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->mustSuggestOptionValuesFor('project') + || $input->mustSuggestArgumentValuesFor('project')) { + $suggestions->suggestValues($this->getProjectAutocompletionSuggestions()); + } elseif ($input->mustSuggestOptionValuesFor('environment') + || $input->mustSuggestArgumentValuesFor('environment') + || $input->mustSuggestArgumentValuesFor('parent')) { + $suggestions->suggestValues($this->getEnvironmentAutocompletionSuggestions($input)); + } elseif ($input->mustSuggestOptionValuesFor('app')) { + $suggestions->suggestValues($this->getAppAutocompletionSuggestions($input)); + } + } + + /** + * Get the preferred project for autocompletion. + * + * The project is either defined by an ID that the user has specified in + * the command (via the 'project' argument or '--project' option), or it is + * determined from the current path. + * + * @param CompletionInput $input + * @return Project|false + */ + private function getProjectForAutocompletion(CompletionInput $input): Project|false + { + if (!$this->api->isLoggedIn()) { + return false; + } + if ($input->hasOption('project')) { + $id = $input->getOption('project'); + } elseif ($input->hasArgument('project')) { + $id = $input->getArgument('project'); + } elseif ($input->hasArgument('get')) { + $id = $input->getArgument('get'); + } else { + $id = null; + } + if ($id !== null) { + return $this->api->getProject($id, null, false); + } + if ($currentProject = $this->getCurrentProject(true)) { + return $currentProject; + } + return false; + } + + /** + * @return Suggestion[] + */ + private function getEnvironmentAutocompletionSuggestions(CompletionInput $input): array + { + $project = $this->getProjectForAutocompletion($input); + if (!$project) { + return []; + } + $environments = $this->api->getEnvironments($project, false, false); + $environments = $environments ?: $this->api->getEnvironments($project, null, false); + $suggestions = array_map( + function (Environment $e): Suggestion { + return new Suggestion($e->id, $e->title && $e->title !== $e->id ? $e->title : ''); + }, + $environments, + ); + if (count($environments) > 1 && $this->api->getDefaultEnvironment($environments, $project, true) !== null) { + array_unshift($suggestions, new Suggestion(self::DEFAULT_ENVIRONMENT_CODE, 'Default environment')); + } + return $suggestions; + } + + /** + * @return Suggestion[] + */ + private function getProjectAutocompletionSuggestions(): array + { + if (!$this->api->isLoggedIn()) { + return []; + } + $projects = $this->api->getMyProjects(false) ?: $this->api->getMyProjects(); + return array_map( + fn(BasicProjectInfo $p): Suggestion => new Suggestion($p->id, $p->title), + $projects, + ); + } + + /** + * @return array + */ + private function getAppAutocompletionSuggestions(CompletionInput $input): array + { + $apps = []; + if ($projectRoot = $this->getProjectRoot()) { + $finder = new ApplicationFinder(); + foreach ($finder->findApplications($projectRoot) as $app) { + $name = $app->getName(); + if ($name !== null) { + $apps[] = $name; + } + } + } elseif ($project = $this->getProjectForAutocompletion($input)) { + $environments = $this->api->getEnvironments($project, null, false); + if ($environments && ($environment = $this->api->getDefaultEnvironment($environments, $project))) { + $apps = array_keys($environment->getSshUrls()); + } + } + + return $apps; + } +} diff --git a/src/Selector/SelectorConfig.php b/src/Selector/SelectorConfig.php new file mode 100644 index 0000000000..378aebe1dd --- /dev/null +++ b/src/Selector/SelectorConfig.php @@ -0,0 +1,57 @@ +envRequired = $envRequired; + } + if ($chooseEnvFilter !== null) { + $obj->chooseEnvFilter = $chooseEnvFilter; + } + return $obj; + } + + /** + * Returns an environment filter to select environments that may be active. + */ + public static function filterEnvsMaybeActive(): callable + { + return fn(Environment $e): bool => \in_array($e->status, ['active', 'dirty'], true) || count($e->getSshUrls()) > 0; + } + + /** + * Returns an environment filter to select environments by status. + * + * @param string[] $statuses + */ + public static function filterEnvsByStatus(array $statuses): callable + { + return fn(Environment $e): bool => \in_array($e->status, $statuses, true); + } +} diff --git a/src/SelfUpdate/ManifestStrategy.php b/src/SelfUpdate/ManifestStrategy.php index bda325aec3..cfa1772329 100644 --- a/src/SelfUpdate/ManifestStrategy.php +++ b/src/SelfUpdate/ManifestStrategy.php @@ -1,4 +1,7 @@ |null */ + private ?array $manifest = null; - /** @var bool */ - private $allowUnstable = false; + /** @var array>|null */ + private ?array $availableVersions = null; - /** @var array */ - private static $requiredKeys = ['sha256', 'version', 'url']; + private const REQUIRED_KEYS = ['sha256', 'version', 'url']; - /** @var int */ - private $manifestTimeout = 10; + private int $manifestTimeout = 10; - /** @var int */ - private $downloadTimeout = 60; + private int $downloadTimeout = 60; - /** @var bool */ - private $ignorePhpReq = false; + private bool $ignorePhpReq = false; - /** @var array */ - private $streamContextOptions = []; + /** @var array */ + private array $streamContextOptions = []; /** * ManifestStrategy constructor. * - * @param string $localVersion The local version. - * @param string $manifestUrl The URL to a JSON manifest file. The - * manifest contains an array of objects, each - * containing a 'version', 'sha256', and 'url'. - * @param bool $allowMajor Whether to allow updating between major - * versions. - * @param bool $allowUnstable Whether to allow updating to an unstable - * version. Ignored if $localVersion is unstable - * and there are no new stable versions. + * @param string $localVersion The local version. + * @param string $manifestUrl The URL to a JSON manifest file. The + * manifest contains an array of objects, each + * containing a 'version', 'sha256', and 'url'. + * @param bool $allowMajor Whether to allow updating between major + * versions. + * @param bool $allowUnstable Whether to allow updating to an unstable + * version. Ignored if $localVersion is unstable + * and there are no new stable versions. */ - public function __construct($localVersion, $manifestUrl, $allowMajor = false, $allowUnstable = false) - { - $this->localVersion = $localVersion; - $this->manifestUrl = $manifestUrl; - $this->allowMajor = $allowMajor; - $this->allowUnstable = $allowUnstable; - } + public function __construct(private readonly string $localVersion, private readonly string $manifestUrl, private readonly bool $allowMajor = false, private readonly bool $allowUnstable = false) {} /** - * @param array $opts + * @param array $opts */ - public function setStreamContextOptions(array $opts) + public function setStreamContextOptions(array $opts): void { $this->streamContextOptions = $opts; } - /** - * @param int $manifestTimeout - */ - public function setManifestTimeout($manifestTimeout) + public function setManifestTimeout(int $manifestTimeout): void { $this->manifestTimeout = $manifestTimeout; } @@ -82,7 +60,7 @@ public function setManifestTimeout($manifestTimeout) /** * {@inheritdoc} */ - public function getCurrentLocalVersion(Updater $updater) + public function getCurrentLocalVersion(Updater $updater): string { return $this->localVersion; } @@ -90,7 +68,7 @@ public function getCurrentLocalVersion(Updater $updater) /** * {@inheritdoc} */ - public function getCurrentRemoteVersion(Updater $updater) + public function getCurrentRemoteVersion(Updater $updater): bool|string { $versions = array_keys($this->getAvailableVersions()); if (!$this->allowMajor) { @@ -116,14 +94,11 @@ public function getCurrentRemoteVersion(Updater $updater) } /** - * Find update/upgrade notes for the new remote version. - * - * @param string $currentVersion - * @param string $targetVersion + * Finds update/upgrade notes for the new remote version. * - * @return array + * @return array */ - public function getUpdateNotesByVersion($currentVersion, $targetVersion) + public function getUpdateNotesByVersion(string $currentVersion, string $targetVersion): array { $notes = []; foreach ($this->getAvailableVersions() as $version => $item) { @@ -139,22 +114,22 @@ public function getUpdateNotesByVersion($currentVersion, $targetVersion) } } } - uksort($notes, function ($a, $b) { return \version_compare($a, $b); }); + uksort($notes, fn($a, $b): int => \version_compare($a, $b)); return $notes; } /** * {@inheritdoc} */ - public function download(Updater $updater) + public function download(Updater $updater): void { $versionInfo = $this->getRemoteVersionInfo($updater); // A relative download URL is treated as relative to the manifest URL. $url = $versionInfo['url']; - if (strpos($url, '//') === false && strpos($this->manifestUrl, '//') !== false) { - $removePath = parse_url($this->manifestUrl, PHP_URL_PATH); - $url = str_replace($removePath, '/' . ltrim($url, '/'), $this->manifestUrl); + if (!str_contains((string) $url, '//') && str_contains($this->manifestUrl, '//')) { + $removePath = (string) parse_url($this->manifestUrl, PHP_URL_PATH); + $url = str_replace($removePath, '/' . ltrim((string) $url, '/'), $this->manifestUrl); } $opts = $this->streamContextOptions; @@ -176,8 +151,8 @@ public function download(Updater $updater) sprintf( 'SHA-256 verification failed: expected %s, actual %s', $versionInfo['sha256'], - $tmpSha - ) + $tmpSha, + ), ); } } @@ -185,20 +160,29 @@ public function download(Updater $updater) /** * Get available versions to update to. * - * @return array + * @return array + * }> * An array keyed by the version name, whose elements are arrays - * containing version information ('version', 'sha256', and 'url'). + * containing version information. */ - private function getAvailableVersions() + private function getAvailableVersions(): array { if (!isset($this->availableVersions)) { $this->availableVersions = []; foreach ($this->getManifest() as $key => $item) { - if ($missing = array_diff(self::$requiredKeys, array_keys($item))) { + if ($missing = array_diff(self::REQUIRED_KEYS, array_keys($item))) { throw new \RuntimeException(sprintf( 'Manifest item %s missing required key(s): %s', $key, - implode(',', $missing) + implode(',', $missing), )); } $this->availableVersions[$item['version']] = $item; @@ -213,9 +197,9 @@ private function getAvailableVersions() * * @param Updater $updater * - * @return array + * @return array */ - private function getRemoteVersionInfo(Updater $updater) + private function getRemoteVersionInfo(Updater $updater): array { $version = $this->getCurrentRemoteVersion($updater); if ($version === false) { @@ -230,11 +214,11 @@ private function getRemoteVersionInfo(Updater $updater) } /** - * Download and decode the JSON manifest file. + * Downloads and decodes the JSON manifest file. * - * @return array + * @return array */ - private function getManifest() + private function getManifest(): array { if (!isset($this->manifest)) { $opts = $this->streamContextOptions; @@ -248,7 +232,7 @@ private function getManifest() if (json_last_error() !== JSON_ERROR_NONE) { throw new JsonParsingException( 'Error parsing manifest file' - . (function_exists('json_last_error_msg') ? ': ' . json_last_error_msg() : '') + . (function_exists('json_last_error_msg') ? ': ' . json_last_error_msg() : ''), ); } } @@ -263,12 +247,12 @@ private function getManifest() * * @return string[] */ - private function filterByLocalMajorVersion(array $versions) + private function filterByLocalMajorVersion(array $versions): array { - list($localMajorVersion, ) = explode('.', $this->localVersion, 2); + [$localMajorVersion, ] = explode('.', $this->localVersion, 2); - return array_filter($versions, function ($version) use ($localMajorVersion) { - list($majorVersion, ) = explode('.', $version, 2); + return array_filter($versions, function ($version) use ($localMajorVersion): bool { + [$majorVersion, ] = explode('.', $version, 2); return $majorVersion === $localMajorVersion; }); } @@ -280,11 +264,11 @@ private function filterByLocalMajorVersion(array $versions) * * @return string[] */ - private function filterByPhpVersion(array $versions) + private function filterByPhpVersion(array $versions): array { $versionInfo = $this->getAvailableVersions(); - return array_filter($versions, function ($version) use ($versionInfo) { + return array_filter($versions, function ($version) use ($versionInfo): bool { if (isset($versionInfo[$version]['php']['min']) && version_compare(PHP_VERSION, $versionInfo[$version]['php']['min'], '<')) { return false; diff --git a/src/Command/User/UserCommandBase.php b/src/Service/AccessApi.php similarity index 80% rename from src/Command/User/UserCommandBase.php rename to src/Service/AccessApi.php index 73d637dd93..49c5f2ad07 100644 --- a/src/Command/User/UserCommandBase.php +++ b/src/Service/AccessApi.php @@ -1,39 +1,41 @@ */ + private static ?array $userCache = []; - /** - * @return bool - */ - protected function centralizedPermissionsEnabled() + private OutputInterface $stdErr; + + public function __construct(private readonly Api $api, private readonly Config $config, OutputInterface $output) + { + $this->stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; + } + + public function centralizedPermissionsEnabled(): bool { - return $this->config()->get('api.centralized_permissions') - && $this->config()->get('api.organizations'); + return $this->config->getBool('api.centralized_permissions') + && $this->config->getBool('api.organizations'); } /** * Loads a legacy project user ("project access" record) by ID. - * - * @param Project $project - * @param string $id - * - * @return ProjectAccess|null */ - private function doLoadLegacyProjectAccessById(Project $project, $id) + private function doLoadLegacyProjectAccessById(Project $project, string $id): ?ProjectAccess { - return ProjectAccess::get($id, $project->getUri() . '/access', $this->api()->getHttpClient()) ?: null; + return ProjectAccess::get($id, $project->getUri() . '/access', $this->api->getHttpClient()) ?: null; } /** @@ -50,9 +52,9 @@ private function doLoadLegacyProjectAccessById(Project $project, $id) * "Centralized Permissions" are enabled, or a ProjectAccess object * otherwise. */ - protected function loadProjectUser(Project $project, $identifier, $reset = false) + public function loadProjectUser(Project $project, string $identifier, bool $reset = false): ProjectUserAccess|ProjectAccess|null { - $byEmail = strpos($identifier, '@') !== false; + $byEmail = str_contains($identifier, '@'); $cacheKey = $project->id . ':' . $identifier; if ($reset || !isset(self::$userCache[$cacheKey])) { if ($this->centralizedPermissionsEnabled()) { @@ -76,7 +78,7 @@ protected function loadProjectUser(Project $project, $identifier, $reset = false * * @return ProjectAccess|null */ - private function doLoadLegacyProjectAccessByEmail(Project $project, $email) + private function doLoadLegacyProjectAccessByEmail(Project $project, string $email): ProjectAccess|null { foreach ($project->getUsers() as $user) { $info = $this->legacyUserInfo($user); @@ -95,7 +97,7 @@ private function doLoadLegacyProjectAccessByEmail(Project $project, $email) * * @return array{id: string, email: string, display_name: string, created_at: string, updated_at: ?string} */ - protected function legacyUserInfo(ProjectAccess $access) + public function legacyUserInfo(ProjectAccess $access): array { $data = $access->getData(); if (isset($data['_embedded']['users'])) { @@ -115,9 +117,9 @@ protected function legacyUserInfo(ProjectAccess $access) * @param Project $project * @return ProjectUserAccess|null */ - private function doLoadProjectUserById(Project $project, $id) + private function doLoadProjectUserById(Project $project, string $id): ?ProjectUserAccess { - $client = $this->api()->getHttpClient(); + $client = $this->api->getHttpClient(); $endpointUrl = $project->getUri() . '/user-access'; return ProjectUserAccess::get($id, $endpointUrl, $client) ?: null; } @@ -131,9 +133,9 @@ private function doLoadProjectUserById(Project $project, $id) * @param Project $project * @return ProjectUserAccess|null */ - private function doLoadProjectUserByEmail(Project $project, $email) + private function doLoadProjectUserByEmail(Project $project, string $email): ?ProjectUserAccess { - $client = $this->api()->getHttpClient(); + $client = $this->api->getHttpClient(); $progress = new ProgressMessage($this->stdErr); $progress->showIfOutputDecorated('Loading user information...'); @@ -180,11 +182,11 @@ private function doLoadProjectUserByEmail(Project $project, $email) * * @return array An array of user labels keyed by user ID. */ - protected function listUsers(Project $project) + public function listUsers(Project $project): array { $choices = []; if ($this->centralizedPermissionsEnabled()) { - $items = ProjectUserAccess::getCollection($project->getUri() . '/user-access', 0, ['query' => ['page[size]' => 200]], $this->api()->getHttpClient()); + $items = ProjectUserAccess::getCollection($project->getUri() . '/user-access', 0, ['query' => ['page[size]' => 200]], $this->api->getHttpClient()); foreach ($items as $item) { $choices[$item->user_id] = $this->getUserLabel($item); } @@ -199,13 +201,8 @@ protected function listUsers(Project $project) /** * Returns a label describing a user. - * - * @param ProjectAccess|ProjectUserAccess $access - * @param bool $formatting - * - * @return string */ - protected function getUserLabel($access, $formatting = false) + public function getUserLabel(ProjectUserAccess|ProjectAccess $access, bool $formatting = false): string { $format = $formatting ? '%s (%s)' : '%s (%s)'; if ($access instanceof ProjectAccess) { diff --git a/src/Service/ActivityLoader.php b/src/Service/ActivityLoader.php index af365fdbb9..205c7e37e7 100644 --- a/src/Service/ActivityLoader.php +++ b/src/Service/ActivityLoader.php @@ -1,5 +1,7 @@ stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; } - /** - * @return \Symfony\Component\Console\Output\OutputInterface - */ - private function getProgressOutput() + private function getProgressOutput(): OutputInterface { return $this->stdErr->isDecorated() ? $this->stdErr : new NullOutput(); } @@ -40,12 +38,12 @@ private function getProgressOutput() * The --state, --incomplete, --result, --start, --limit, and --type options will be processed. * * @param int|null $limit Limit the number of activities to return, regardless of input. - * @param array $state Define the states to return, regardless of input. + * @param string[] $state Define the states to return, regardless of input. * @param string $withOperation Filters the resulting activities to those with the specified operation available. * - * @return \Platformsh\Client\Model\Activity[] + * @return Activity[] */ - public function loadFromInput(HasActivitiesInterface $apiResource, InputInterface $input, $limit = null, $state = [], $withOperation = '') + public function loadFromInput(HasActivitiesInterface $apiResource, InputInterface $input, ?int $limit = null, array $state = [], string $withOperation = ''): array { if ($state === [] && $input->hasOption('state')) { $state = ArrayArgument::getOption($input, 'state'); @@ -57,7 +55,7 @@ public function loadFromInput(HasActivitiesInterface $apiResource, InputInterfac } } if ($limit === null) { - $limit = $input->hasOption('limit') ? $input->getOption('limit') : null; + $limit = $input->hasOption('limit') ? (int) $input->getOption('limit') : null; } $availableTypes = self::getAvailableTypes(); $requestedIncludeTypes = $input->hasOption('type') ? ArrayArgument::getOption($input, 'type') : []; @@ -88,9 +86,7 @@ public function loadFromInput(HasActivitiesInterface $apiResource, InputInterfac } } if (empty($typesFilter) && !empty($typesToExclude)) { - $typesFilter = \array_filter($availableTypes, function ($type) use ($typesToExclude) { - return !\in_array($type, $typesToExclude, true); - }); + $typesFilter = \array_filter($availableTypes, fn($type): bool => !\in_array($type, $typesToExclude, true)); } if (!empty($typesFilter) && $this->stdErr->isDebug()) { $this->stdErr->writeln('DEBUG Selected activity type(s): ' . implode(',', $typesFilter)); @@ -102,9 +98,7 @@ public function loadFromInput(HasActivitiesInterface $apiResource, InputInterfac } $activities = $this->load($apiResource, $limit, $typesFilter, $startsAt, $state, $result); if ($withOperation) { - $activities = array_filter($activities, function (Activity $activity) use ($withOperation) { - return $activity->operationAvailable($withOperation); - }); + $activities = array_filter($activities, fn(Activity $activity): bool => $activity->operationAvailable($withOperation)); } return $activities; } @@ -121,9 +115,9 @@ public function loadFromInput(HasActivitiesInterface $apiResource, InputInterfac * @param callable|null $stopCondition * A test to perform on each activity. If it returns true, loading is stopped. * - * @return \Platformsh\Client\Model\Activity[] + * @return Activity[] */ - public function load(HasActivitiesInterface $apiResource, $limit = null, array $types = [], $startsAt = null, $state = null, $result = null, callable $stopCondition = null) + public function load(HasActivitiesInterface $apiResource, ?int $limit = null, array $types = [], int|DateTime|null $startsAt = null, array|string|null $state = null, array|string|null $result = null, ?callable $stopCondition = null): array { $progress = new ProgressBar($this->getProgressOutput()); $progress->setMessage('Loading activities...'); @@ -164,7 +158,7 @@ public function load(HasActivitiesInterface $apiResource, $limit = null, array $ * * @return string[] */ - public static function getAvailableTypes() + public static function getAvailableTypes(): array { return [ 'environment.access.add', @@ -233,7 +227,7 @@ public static function getAvailableTypes() 'project.modify.title', 'project.variable.create', 'project.variable.delete', - 'project.variable.update' + 'project.variable.update', ]; } } diff --git a/src/Service/ActivityMonitor.php b/src/Service/ActivityMonitor.php index 0eb9f44eec..d81e04f48e 100644 --- a/src/Service/ActivityMonitor.php +++ b/src/Service/ActivityMonitor.php @@ -1,66 +1,103 @@ 'failure', Activity::RESULT_SUCCESS => 'success', ]; - protected static $stateNames = [ + private const STATE_NAMES = [ Activity::STATE_PENDING => 'pending', Activity::STATE_COMPLETE => 'complete', Activity::STATE_IN_PROGRESS => 'in progress', Activity::STATE_CANCELLED => 'cancelled', ]; - protected $output; - protected $config; - protected $api; + private readonly OutputInterface $stdErr; + + public function __construct(private readonly Config $config, private readonly Api $api, private readonly Io $io, OutputInterface $output) + { + $this->stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; + } /** - * @param OutputInterface $output - * @param Config $config - * @param Api $api + * Indents a multi-line string. */ - public function __construct(OutputInterface $output, Config $config, Api $api) + protected function indent(string $string, string $prefix = ' '): string { - $this->output = $output; - $this->config = $config; - $this->api = $api; + return preg_replace('/^/m', $prefix, $string); } /** - * @return \Symfony\Component\Console\Output\OutputInterface + * Add both the --no-wait and --wait options. */ - protected function getStdErr() + public function addWaitOptions(InputDefinition $definition): void { - return $this->output instanceof ConsoleOutputInterface ? $this->output->getErrorOutput() : $this->output; + $definition->addOption(new InputOption('no-wait', 'W', InputOption::VALUE_NONE, 'Do not wait for the operation to complete')); + if ($this->detectRunningInHook()) { + $definition->addOption(new InputOption('wait', null, InputOption::VALUE_NONE, 'Wait for the operation to complete')); + } else { + $definition->addOption(new InputOption('wait', null, InputOption::VALUE_NONE, 'Wait for the operation to complete (default)')); + } } /** - * Indent a multi-line string. - * - * @param string $string - * @param string $prefix + * Returns whether we should wait for an operation to complete. + */ + public function shouldWait(InputInterface $input): bool + { + if ($input->hasOption('no-wait') && $input->getOption('no-wait')) { + return false; + } + if ($input->hasOption('wait') && $input->getOption('wait')) { + return true; + } + if ($this->detectRunningInHook()) { + $serviceName = $this->config->getStr('service.name'); + $message = "\nWarning: $serviceName hook environment detected: assuming --no-wait by default." + . "\nTo avoid ambiguity, please specify either --no-wait or --wait." + . "\n"; + $this->stdErr->writeln($message); + + return false; + } + + return true; + } + + /** + * Detects a Platform.sh non-terminal Dash environment; i.e. a hook. * - * @return string + * @return bool */ - protected function indent($string, $prefix = ' ') + private function detectRunningInHook(): bool { - return preg_replace('/^/m', $prefix, $string); + $envPrefix = $this->config->getStr('service.env_prefix'); + if (getenv($envPrefix . 'PROJECT') + && basename((string) getenv('SHELL')) === 'dash' + && !$this->io->isTerminal(STDIN)) { + return true; + } + + return false; } /** @@ -75,9 +112,9 @@ protected function indent($string, $prefix = ' ') * * @return bool True if the activity succeeded, false otherwise. */ - public function waitAndLog(Activity $activity, $pollInterval = 3, $timestamps = false, $context = true, OutputInterface $logOutput = null, $noResult = false) + public function waitAndLog(Activity $activity, int $pollInterval = 3, bool|string $timestamps = false, bool $context = true, ?OutputInterface $logOutput = null, bool $noResult = false): bool { - $stdErr = $this->getStdErr(); + $stdErr = $this->stdErr; $logOutput = $logOutput ?: $stdErr; if ($context) { @@ -94,10 +131,8 @@ public function waitAndLog(Activity $activity, $pollInterval = 3, $timestamps = return $this->formatState($overrideState ?: $activity->state); }); $startTime = $this->getStart($activity) ?: time(); - $bar->setPlaceholderFormatterDefinition('elapsed', function () use ($startTime) { - return $this->formatDuration(time() - $startTime); - }); - $bar->setPlaceholderFormatterDefinition('fgColor', function () use (&$progressColor) { return $progressColor; }); + $bar->setPlaceholderFormatterDefinition('elapsed', fn() => $this->formatDuration(time() - $startTime)); + $bar->setPlaceholderFormatterDefinition('fgColor', function () use (&$progressColor): string { return $progressColor; }); $bar->setFormat('[%bar%] %elapsed:6s% (%state%)'); $bar->start(); @@ -233,7 +268,7 @@ public function waitAndLog(Activity $activity, $pollInterval = 3, $timestamps = * * @return string */ - private function formatDuration($value) + private function formatDuration(int|float $value): string { $hours = $minutes = 0; $seconds = (int) round($value); @@ -256,7 +291,8 @@ private function formatDuration($value) * * @return array{'items': LogItem[], 'seal': bool} */ - private function parseLog(&$buffer) { + private function parseLog(string &$buffer): array + { if (\strlen($buffer) <= 1) { return ['items' => [], 'seal' => false]; } @@ -280,14 +316,15 @@ private function parseLog(&$buffer) { * * @return string */ - public function formatLog(array $items, $timestamps = false) { + public function formatLog(array $items, bool|string $timestamps = false): string + { $timestampFormat = false; if ($timestamps !== false) { - $timestampFormat = $timestamps ?: $this->config->getWithDefault('application.date_format', 'Y-m-d H:i:s'); + $timestampFormat = $timestamps === true ? $this->config->getStr('application.date_format') : $timestamps; } - $formatItem = function (LogItem $item) use ($timestampFormat) { + $formatItem = function (LogItem $item) use ($timestampFormat): string { if ($timestampFormat !== false) { - return '[' . $item->getTime()->format($timestampFormat) . '] '. $item->getMessage(); + return '[' . $item->getTime()->format($timestampFormat) . '] ' . $item->getMessage(); } return $item->getMessage(); @@ -311,9 +348,9 @@ public function formatLog(array $items, $timestamps = false) { * @return bool * True if all activities succeed, false otherwise. */ - public function waitMultiple(array $activities, Project $project, $context = true, $noLog = false, $noResult = false) + public function waitMultiple(array $activities, Project $project, bool $context = true, bool $noLog = false, bool $noResult = false): bool { - $stdErr = $this->getStdErr(); + $stdErr = $this->stdErr; // If there is 1 activity then display its log. $count = count($activities); @@ -324,12 +361,8 @@ public function waitMultiple(array $activities, Project $project, $context = tru } // Split integration and non-integration activities, and put the latter first. - $integrationActivities = array_filter($activities, function (Activity $a) { - return strpos($a->type, 'integration.') === 0; - }); - $nonIntegrationActivities = array_filter($activities, function (Activity $a) { - return strpos($a->type, 'integration.') !== 0; - }); + $integrationActivities = array_filter($activities, fn(Activity $a): bool => str_starts_with($a->type, 'integration.')); + $nonIntegrationActivities = array_filter($activities, fn(Activity $a): bool => !str_starts_with($a->type, 'integration.')); $activities = array_merge($nonIntegrationActivities, $integrationActivities); // For more than one activity, output a list of their descriptions. @@ -397,7 +430,7 @@ public function waitMultiple(array $activities, Project $project, $context = tru $stdErr->writeln(sprintf('%s finished with an unknown result:', $summaryCount, $fgColor)); } foreach ($items as $item) { - list($num, $activity) = $item; + [$num, $activity] = $item; $stdErr->writeln(sprintf(' #%d %s', $fgColor, $num, self::getFormattedDescription($activity, true, true, $fgColor))); if ($showLog) { $stdErr->writeln(' Log:'); @@ -432,10 +465,8 @@ public function waitMultiple(array $activities, Project $project, $context = tru } return implode(', ', $withCount); }); - $bar->setPlaceholderFormatterDefinition('fgColor', function () use (&$progressColor) { return $progressColor; }); - $bar->setPlaceholderFormatterDefinition('elapsed', function () use ($startTime, &$progressColor) { - return $this->formatDuration(time() - $startTime); - }); + $bar->setPlaceholderFormatterDefinition('fgColor', function () use (&$progressColor): string { return $progressColor; }); + $bar->setPlaceholderFormatterDefinition('elapsed', fn() => $this->formatDuration(time() - $startTime)); $bar->start(); // Get the most recent created date of each of the activities, as a Unix @@ -516,9 +547,9 @@ public function waitMultiple(array $activities, Project $project, $context = tru * * @return bool Success or failure. */ - private function printResult(Activity $activity, $logOnFailure = false) + private function printResult(Activity $activity, bool $logOnFailure = false): bool { - $stdErr = $this->getStdErr(); + $stdErr = $this->stdErr; // Display the success or failure messages. switch ($activity->result) { @@ -545,28 +576,19 @@ private function printResult(Activity $activity, $logOnFailure = false) } /** - * Format a state name. - * - * @param string $state - * - * @return string + * Formats a state name. */ - public static function formatState($state) + public static function formatState(string $state): string { - return isset(self::$stateNames[$state]) ? self::$stateNames[$state] : $state; + return self::STATE_NAMES[$state] ?? $state; } /** - * Format a result. - * - * @param string $result - * @param bool $decorate - * - * @return string + * Formats an activity result. */ - public static function formatResult($result, $decorate = true) + public static function formatResult(string $result, bool $decorate = true): string { - $name = isset(self::$resultNames[$result]) ? self::$resultNames[$result] : $result; + $name = self::RESULT_NAMES[$result] ?? $result; return $decorate && $result === Activity::RESULT_FAILURE ? '' . $name . '' @@ -580,7 +602,7 @@ public static function formatResult($result, $decorate = true) * * @return ProgressBar */ - protected function newProgressBar(OutputInterface $output) + protected function newProgressBar(OutputInterface $output): ProgressBar { // If the console output is not decorated (i.e. it does not support // ANSI), use NullOutput to suppress the progress bar entirely. @@ -592,14 +614,14 @@ protected function newProgressBar(OutputInterface $output) /** * Get the formatted description of an activity. * - * @param \Platformsh\Client\Model\Activity $activity The activity. + * @param Activity $activity The activity. * @param bool $withDecoration Add decoration to activity tags. * @param bool $withId Add the activity ID. * @param string $fgColor Define a foreground color e.g. 'green', 'red', 'cyan'. * * @return string */ - public static function getFormattedDescription(Activity $activity, $withDecoration = true, $withId = false, $fgColor = '') + public static function getFormattedDescription(Activity $activity, bool $withDecoration = true, bool $withId = false, string $fgColor = ''): string { if (!$withDecoration) { if ($withId) { @@ -612,14 +634,14 @@ public static function getFormattedDescription(Activity $activity, $withDecorati // Replace description HTML elements with Symfony Console decoration // tags. $descr = preg_replace('@<[^/][^>]+>@', '', $descr); - $descr = preg_replace('@]+>@', '', $descr); + $descr = preg_replace('@]+>@', '', (string) $descr); // Replace literal tags like "<info&;gt;" with escaped tags like // "\". - $descr = preg_replace('@<(/?[a-z][a-z0-9,_=;-]*+)>@i', '\\\<$1>', $descr); + $descr = preg_replace('@<(/?[a-z][a-z0-9,_=;-]*+)>@i', '\\\<$1>', (string) $descr); // Decode other HTML entities. - $descr = html_entity_decode($descr, ENT_QUOTES, 'utf-8'); + $descr = html_entity_decode((string) $descr, ENT_QUOTES, 'utf-8'); if ($withId) { if ($fgColor) { @@ -636,7 +658,8 @@ public static function getFormattedDescription(Activity $activity, $withDecorati * * @return false|int */ - private function getStart(Activity $activity) { + private function getStart(Activity $activity): int|false + { return !empty($activity->started_at) ? strtotime($activity->started_at) : strtotime($activity->created_at); } @@ -649,7 +672,8 @@ private function getStart(Activity $activity) { * * @return resource */ - private function getLogStream(Activity $activity, ProgressBar $bar) { + private function getLogStream(Activity $activity, ProgressBar $bar) + { $url = $activity->getLink('log'); // Try fetching the stream with a 10 second timeout per call, and a .5 @@ -657,9 +681,9 @@ private function getLogStream(Activity $activity, ProgressBar $bar) { $readTimeout = 10; $interval = .5; - if ($this->config->getWithDefault('api.debug', false)) { + if ($this->config->getBool('api.debug')) { $bar->clear(); - $stdErr = $this->getStdErr(); + $stdErr = $this->stdErr; $stdErr->write($stdErr->isDecorated() ? "\n\033[1A" : "\n"); $stdErr->writeln('DEBUG Fetching stream: ' . $url); $bar->display(); @@ -672,7 +696,7 @@ private function getLogStream(Activity $activity, ProgressBar $bar) { throw new \RuntimeException('Failed to open activity log stream: ' . $url); } $bar->advance(); - \usleep($interval * 1000000); + \usleep((int) $interval * 1000000); $bar->advance(); $stream = \fopen($url, 'r', false, $this->api->getStreamContext($readTimeout)); } diff --git a/src/Service/Api.php b/src/Service/Api.php index 923bb0f4b4..f5221cddbc 100644 --- a/src/Service/Api.php +++ b/src/Service/Api.php @@ -1,22 +1,28 @@ */ - private static $environmentsCache = []; + private static array $environmentsCache = []; /** * A cache of environment deployments. * * @var array */ - private static $deploymentsCache = []; + private static array $deploymentsCache = []; /** * A cache of not-found environment IDs. * - * @see Api::getEnvironment() + * @var array * - * @var string[] + * @see Api::getEnvironment() */ - private static $notFound = []; + private static array $notFound = []; /** * Session storage, via files or credential helpers. * * @see Api::initSessionStorage() - * - * @var \Platformsh\Client\Session\Storage\SessionStorageInterface|null */ - private $sessionStorage; + private ?SessionStorageInterface $sessionStorage = null; /** * Sets whether we are currently verifying login using a test request. - * - * @var bool */ - public $inLoginCheck = false; + public bool $inLoginCheck = false; /** * Constructor. @@ -131,24 +125,48 @@ class Api * @param TokenConfig|null $tokenConfig * @param EventDispatcherInterface|null $dispatcher * @param FileLock|null $fileLock + * @param Io|null $io */ public function __construct( - Config $config = null, - CacheProvider $cache = null, - OutputInterface $output = null, - TokenConfig $tokenConfig = null, - FileLock $fileLock = null, - EventDispatcherInterface $dispatcher = null + ?Config $config = null, + ?CacheProvider $cache = null, + ?OutputInterface $output = null, + ?Io $io = null, + ?TokenConfig $tokenConfig = null, + ?FileLock $fileLock = null, + ?EventDispatcherInterface $dispatcher = null, ) { $this->config = $config ?: new Config(); $this->output = $output ?: new ConsoleOutput(); - $this->stdErr = $this->output instanceof ConsoleOutputInterface ? $this->output->getErrorOutput(): $this->output; + $this->stdErr = $this->output instanceof ConsoleOutputInterface ? $this->output->getErrorOutput() : $this->output; + $this->io = $io ?: new Io($this->output); $this->tokenConfig = $tokenConfig ?: new TokenConfig($this->config); $this->fileLock = $fileLock ?: new FileLock($this->config); $this->dispatcher = $dispatcher ?: new EventDispatcher(); $this->cache = $cache ?: CacheFactory::createCacheProvider($this->config); } + /** + * Sets up listeners (called by the DI container). + * + * @param AutoLoginListener $autoLoginListener + * @param DrushAliasUpdater $drushAliasUpdater + */ + #[Required] + public function injectListeners( + AutoLoginListener $autoLoginListener, + DrushAliasUpdater $drushAliasUpdater, + ): void { + $this->dispatcher->addListener( + 'login.required', + $autoLoginListener->onLoginRequired(...), + ); + $this->dispatcher->addListener( + 'environments.changed', + $drushAliasUpdater->onEnvironmentsChanged(...), + ); + } + /** * Returns whether the CLI is authenticating using an API token. * @@ -156,7 +174,7 @@ public function __construct( * * @return bool */ - public function hasApiToken($includeStored = true) + public function hasApiToken(bool $includeStored = true): bool { return $this->tokenConfig->getAccessToken() || $this->tokenConfig->getApiToken($includeStored); } @@ -168,7 +186,7 @@ public function hasApiToken($includeStored = true) * * @return string[] */ - public function listSessionIds() + public function listSessionIds(): array { $ids = []; if ($this->sessionStorage instanceof CredentialHelperStorage) { @@ -176,14 +194,14 @@ public function listSessionIds() } $dir = $this->config->getSessionDir(); $files = glob($dir . '/sess-cli-*', GLOB_NOSORT); - foreach ($files as $file) { - if (\preg_match('@/sess-cli-([a-z0-9_-]+)@i', $file, $matches)) { - $ids[] = $matches[1]; + if ($files !== false) { + foreach ($files as $file) { + if (\preg_match('@/sess-cli-([a-z0-9_-]+)@i', $file, $matches)) { + $ids[] = $matches[1]; + } } } - $ids = \array_filter($ids, function ($id) { - return strpos($id, 'api-token-') !== 0; - }); + $ids = \array_filter($ids, fn($id): bool => !str_starts_with((string) $id, 'api-token-')); return \array_unique($ids); } @@ -193,7 +211,7 @@ public function listSessionIds() * * @return bool */ - public function anySessionsExist() + public function anySessionsExist(): bool { if ($this->sessionStorage instanceof CredentialHelperStorage && $this->sessionStorage->hasAnySessions()) { return true; @@ -207,7 +225,7 @@ public function anySessionsExist() /** * Logs out of the current session. */ - public function logout() + public function logout(): void { // Delete the stored API token, if any. $this->tokenConfig->storage()->deleteToken(); @@ -222,7 +240,7 @@ public function logout() // Ensure the session directory is wiped. $dir = $this->config->getSessionDir(true); if (is_dir($dir)) { - (new \Symfony\Component\Filesystem\Filesystem())->remove($dir); + (new Filesystem())->remove($dir); } // Wipe the client so it is re-initialized when needed. @@ -232,14 +250,14 @@ public function logout() /** * Deletes all sessions. */ - public function deleteAllSessions() + public function deleteAllSessions(): void { if ($this->sessionStorage instanceof CredentialHelperStorage) { $this->sessionStorage->deleteAll(); } $dir = $this->config->getSessionDir(); if (is_dir($dir)) { - (new \Symfony\Component\Filesystem\Filesystem())->remove($dir); + (new Filesystem())->remove($dir); } } @@ -248,21 +266,22 @@ public function deleteAllSessions() * * @see Connector::__construct() * - * @return array + * @return array */ - private function getConnectorOptions() { + private function getConnectorOptions(): array + { $connectorOptions = []; $connectorOptions['api_url'] = $this->config->getApiUrl(); if ($this->config->has('api.accounts_api_url')) { $connectorOptions['accounts'] = $this->config->get('api.accounts_api_url'); } $connectorOptions['certifier_url'] = $this->config->get('api.certifier_url'); - $connectorOptions['verify'] = $this->config->getWithDefault('api.skip_ssl', false) ? false : $this->caBundlePath(); + $connectorOptions['verify'] = $this->config->getBool('api.skip_ssl') ? false : $this->caBundlePath(); $connectorOptions['debug'] = false; $connectorOptions['client_id'] = $this->config->get('api.oauth2_client_id'); $connectorOptions['user_agent'] = $this->config->getUserAgent(); - $connectorOptions['timeout'] = $this->config->getWithDefault('api.default_timeout', 30); + $connectorOptions['timeout'] = $this->config->getInt('api.default_timeout'); if ($apiToken = $this->tokenConfig->getApiToken()) { $connectorOptions['api_token'] = $apiToken; @@ -281,31 +300,38 @@ private function getConnectorOptions() { // different CLI processes. $refreshLockName = 'refresh--' . $this->config->getSessionIdSlug(); $connectorOptions['on_refresh_start'] = function ($originalRefreshToken) use ($refreshLockName) { - $this->debug('Refreshing access token'); + $this->io->debug('Refreshing access token'); $connector = $this->getClient(false)->getConnector(); - return $this->fileLock->acquireOrWait($refreshLockName, function () { + return $this->fileLock->acquireOrWait($refreshLockName, function (): void { $this->stdErr->writeln('Waiting for token refresh lock', OutputInterface::VERBOSITY_VERBOSE); }, function () use ($connector, $originalRefreshToken) { $session = $connector->getSession(); - $session->load(true); $accessToken = $this->tokenFromSession($session); return $accessToken && $accessToken->getRefreshToken() !== $originalRefreshToken ? $accessToken : null; }); }; - $connectorOptions['on_refresh_end'] = function () use ($refreshLockName) { + $connectorOptions['on_refresh_end'] = function () use ($refreshLockName): void { $this->fileLock->release($refreshLockName); }; - $connectorOptions['on_refresh_error'] = function (BadResponseException $e) { - return $this->onRefreshError($e); - }; + $connectorOptions['on_refresh_error'] = fn(IdentityProviderException $e): ?AccessToken => $this->onRefreshError($e); - $connectorOptions['on_step_up_auth_response'] = function (ResponseInterface $response) { - return $this->onStepUpAuthResponse($response); - }; + $connectorOptions['on_step_up_auth_response'] = fn(ResponseInterface $response) => $this->onStepUpAuthResponse($response); + + $connectorOptions['centralized_permissions_enabled'] = $this->config->getBool('api.centralized_permissions') && $this->config->getBool('api.organizations'); - $connectorOptions['centralized_permissions_enabled'] = $this->config->get('api.centralized_permissions') && $this->config->get('api.organizations'); + // Add middlewares. + $connectorOptions['middlewares'] = []; + // Debug responses. + $connectorOptions['middlewares'][] = new GuzzleDebugMiddleware($this->output, $this->config->getBool('api.debug')); + // Handle 403 errors. + $connectorOptions['middlewares'][] = fn(callable $handler): \Closure => fn(RequestInterface $request, array $options) => $handler($request, $options)->then(function (ResponseInterface $response) use ($request): ResponseInterface { + if ($response->getStatusCode() === 403) { + $this->on403($request); + } + return $response; + }); return $connectorOptions; } @@ -319,46 +345,32 @@ private function getConnectorOptions() { * * @return string */ - private function caBundlePath() + private function caBundlePath(): string { static $path; if ($path === null) { $path = CaBundle::getSystemCaRootBundlePath(); - $this->debug('Determined CA bundle path: ' . $path); + $this->io->debug('Determined CA bundle path: ' . $path); } return $path; } - /** - * Logs a debug message. - * - * @param string $message - * @return void - */ - private function debug($message) + private function onStepUpAuthResponse(ResponseInterface $response): ?AccessToken { - if ($this->stdErr && $this->stdErr->isDebug()) { - $this->stdErr->writeln('DEBUG ' . $message); - } - } - - private function onStepUpAuthResponse(ResponseInterface $response) { if ($this->inLoginCheck) { return null; } - if ($this->stdErr->isVeryVerbose()) { - $this->stdErr->writeln(ApiResponseException::getErrorDetails($response)); - } + $this->io->debug(ApiResponseException::getErrorDetails($response)); $session = $this->getClient(false)->getConnector()->getSession(); $previousAccessToken = $session->get('accessToken'); - $body = $response->json(); - $authMethods = isset($body['amr']) ? $body['amr'] : []; - $maxAge = isset($body['max_age']) ? $body['max_age'] : null; + $body = (array) Utils::jsonDecode((string) $response->getBody(), true); + $authMethods = $body['amr'] ?? []; + $maxAge = $body['max_age'] ?? null; - $this->dispatcher->dispatch('login_required', new LoginRequiredEvent($authMethods, $maxAge, $this->hasApiToken())); + $this->dispatcher->dispatch(new LoginRequiredEvent($authMethods, $maxAge, $this->hasApiToken()), 'login.required'); $this->stdErr->writeln(''); $session = $this->getClient(false)->getConnector()->getSession(); @@ -373,36 +385,35 @@ private function onStepUpAuthResponse(ResponseInterface $response) { /** * Logs out and prompts for re-authentication after a token refresh error. * - * @param BadResponseException $e + * @param IdentityProviderException $e * * @return AccessToken|null */ - private function onRefreshError(BadResponseException $e) { - $response = $e->getResponse(); - if ($response && !in_array($response->getStatusCode(), [400, 401])) { + private function onRefreshError(IdentityProviderException $e): ?AccessToken + { + if ($this->inLoginCheck) { return null; } - if ($this->inLoginCheck) { + $data = $e->getResponseBody(); + if (!is_array($data) || !isset($data['error'])) { return null; } + $this->io->debug($e->getMessage()); + $this->logout(); - if ($this->isSsoSessionExpired($e)) { + if ($this->isSsoSessionExpired($data)) { $this->stdErr->writeln('Your SSO session has expired. You have been logged out.'); - } elseif ($this->isApiTokenInvalid($e)) { + } elseif ($this->isApiTokenInvalid($data)) { $this->stdErr->writeln('The API token is invalid.'); } else { $this->stdErr->writeln('Your session has expired. You have been logged out.'); } - if ($response && $this->stdErr->isVeryVerbose()) { - $this->stdErr->writeln($e->getMessage() . ApiResponseException::getErrorDetails($response)); - } - $this->stdErr->writeln(''); - $this->dispatcher->dispatch('login_required', new LoginRequiredEvent([], null, $this->hasApiToken())); + $this->dispatcher->dispatch(new LoginRequiredEvent([], null, $this->hasApiToken()), 'login.required'); $session = $this->getClient(false)->getConnector()->getSession(); return $this->tokenFromSession($session); @@ -411,38 +422,27 @@ private function onRefreshError(BadResponseException $e) { /** * Tests if an HTTP response from refreshing a token indicates that the user's SSO session has expired. * - * @param BadResponseException $e - * @return bool + * @param array $data */ - private function isSsoSessionExpired(BadResponseException $e) + private function isSsoSessionExpired(array $data): bool { - if (!($response = $e->getResponse()) || $response->getStatusCode() !== 400) { - return false; + if (isset($data['error']) && $data['error'] === 'invalid_grant') { + return isset($data['error_description']) + && str_contains((string) $data['error_description'], 'SSO session has expired'); } - $respBody = (string) $response->getBody(); - $errDetails = \json_decode($respBody, true); - return isset($errDetails['error_description']) - && strpos($errDetails['error_description'], 'SSO session has expired') !== false; + return false; } /** * Tests if an error from refreshing a token indicates that the user's API token is invalid. * - * @param BadResponseException $e - * @return bool + * @param array $body */ - private function isApiTokenInvalid(BadResponseException $e) + private function isApiTokenInvalid(mixed $body): bool { - if (!$response = $e->getResponse()) { - return false; - } - $reqBody = (string) $e->getRequest()->getBody(); - \parse_str($reqBody, $parsed); - if (isset($parsed['grant_type']) && $parsed['grant_type'] === 'api_token') { - $respBody = (string) $response->getBody(); - $errDetails = \json_decode($respBody, true); - return isset($errDetails['error_description']) - && strpos($errDetails['error_description'], 'API token') !== false; + if (is_array($body) && isset($body['error']) && $body['error'] === 'invalid_grant') { + return isset($body['error_description']) + && str_contains((string) $body['error_description'], 'API token'); } return false; } @@ -454,7 +454,8 @@ private function isApiTokenInvalid(BadResponseException $e) * * @return AccessToken|null */ - private function tokenFromSession(SessionInterface $session) { + private function tokenFromSession(SessionInterface $session): ?AccessToken + { if (!$session->get('accessToken')) { return null; } @@ -472,7 +473,7 @@ private function tokenFromSession(SessionInterface $session) { } } - return new AccessToken($tokenData['access_token'], $session->get('tokenType') ?: null, $tokenData); + return new AccessToken($tokenData); } /** @@ -480,22 +481,22 @@ private function tokenFromSession(SessionInterface $session) { * * @see Client::__construct() * - * @return array + * @return array */ - public function getGuzzleOptions() { + public function getGuzzleOptions(): array + { $options = [ - 'defaults' => [ - 'headers' => ['User-Agent' => $this->config->getUserAgent()], - 'debug' => false, - 'verify' => $this->config->getWithDefault('api.skip_ssl', false) ? false : $this->caBundlePath(), - 'proxy' => $this->guzzleProxyConfig(), - 'timeout' => $this->config->getWithDefault('api.default_timeout', 30), - ], + 'headers' => ['User-Agent' => $this->config->getUserAgent()], + 'debug' => false, + 'verify' => $this->config->getBool('api.skip_ssl') ? false : $this->caBundlePath(), + 'proxy' => $this->guzzleProxyConfig(), + 'timeout' => $this->config->getInt('api.default_timeout'), ]; - if ($this->output->isVeryVerbose()) { - $options['defaults']['subscribers'][] = new GuzzleDebugSubscriber($this->output, $this->config->getWithDefault('api.debug', false)); - } + // TODO provide this as a middleware + // if ($this->output->isVeryVerbose()) { + // $options['defaults']['subscribers'][] = new GuzzleDebugMiddleware($this->output, $this->config->getBool('api.debug')); + // } if (extension_loaded('zlib')) { $options['defaults']['decode_content'] = true; @@ -510,9 +511,9 @@ public function getGuzzleOptions() { * * @return string[] */ - private function guzzleProxyConfig() + private function guzzleProxyConfig(): array { - return array_map(function($proxyUrl) { + return array_map(function ($proxyUrl) { // If Guzzle is going to use PHP's built-in HTTP streams, // rather than curl, then transform the proxy scheme. if (!\extension_loaded('curl') && \ini_get('allow_url_fopen')) { @@ -531,7 +532,7 @@ private function guzzleProxyConfig() * * @return PlatformClient */ - public function getClient($autoLogin = true, $reset = false) + public function getClient(bool $autoLogin = true, bool $reset = false): PlatformClient { if (!isset(self::$client) || $reset) { $options = $this->getConnectorOptions(); @@ -542,7 +543,7 @@ public function getClient($autoLogin = true, $reset = false) // This ensures file storage from other credentials will not be // reused. if (!empty($options['api_token'])) { - $sessionId = 'api-token-' . \substr(\hash('sha256', $options['api_token']), 0, 32); + $sessionId = 'api-token-' . \substr(\hash('sha256', (string) $options['api_token']), 0, 32); } // Set up a session to store OAuth2 tokens. @@ -553,7 +554,7 @@ public function getClient($autoLogin = true, $reset = false) // (unless an access token was set directly). if (!isset($options['api_token']) || $options['api_token_type'] !== 'access') { $this->initSessionStorage(); - $this->debug('Loading session'); + $this->io->debug('Loading session'); try { $session->setStorage($this->sessionStorage); } catch (\RuntimeException $e) { @@ -573,48 +574,56 @@ public function getClient($autoLogin = true, $reset = false) self::$client = new PlatformClient($connector); - if ($autoLogin && !$connector->isLoggedIn()) { - $this->dispatcher->dispatch('login_required', new LoginRequiredEvent([], null, $this->hasApiToken())); + if (!self::$printedApiTokenWarning && $this->onContainer() && (getenv($this->config->getStr('application.env_prefix') . 'TOKEN') || $this->hasApiToken(false))) { + $this->stdErr->writeln('Warning:'); + $this->stdErr->writeln('An API token is set. Anyone with SSH access to this environment can read the token.'); + $this->stdErr->writeln('Please ensure the token only has strictly necessary access.'); + $this->stdErr->writeln(''); + self::$printedApiTokenWarning = true; } - try { - $emitter = $connector->getClient()->getEmitter(); - $emitter->on('error', function (ErrorEvent $event) { - if ($event->getResponse() && $event->getResponse()->getStatusCode() === 403) { - $this->on403($event); - } - }); - if ($this->output->isVeryVerbose()) { - $emitter->attach(new GuzzleDebugSubscriber($this->output, $this->config->getWithDefault('api.debug', false))); - } - } catch (\RuntimeException $e) { - // Ignore errors if the user is not logged in at this stage. + if ($autoLogin && !$connector->isLoggedIn()) { + $this->dispatcher->dispatch(new LoginRequiredEvent([], null, $this->hasApiToken()), 'login.required'); } } return self::$client; } + /** + * Detects if running on an application container. + * + * @return bool + */ + private function onContainer(): bool + { + $envPrefix = $this->config->getStr('service.env_prefix'); + return getenv($envPrefix . 'PROJECT') !== false + && getenv($envPrefix . 'BRANCH') !== false + && getenv($envPrefix . 'TREE_ID') !== false; + } + /** * Initializes session credential storage. */ - private function initSessionStorage() { + private function initSessionStorage(): void + { if (!isset($this->sessionStorage)) { // Attempt to use the docker-credential-helpers. $manager = new Manager($this->config); if ($manager->isSupported()) { if ($manager->isInstalled()) { - $this->debug('Using Docker credential helper for session storage'); + $this->io->debug('Using Docker credential helper for session storage'); } else { - $this->debug('Installing Docker credential helper for session storage'); + $this->io->debug('Installing Docker credential helper for session storage'); $manager->install(); } - $this->sessionStorage = new CredentialHelperStorage($manager, $this->config->get('application.slug')); + $this->sessionStorage = new CredentialHelperStorage($manager, $this->config->getStr('application.slug')); return; } // Fall back to file storage. - $this->debug('Using filesystem for session storage'); + $this->io->debug('Using filesystem for session storage'); $this->sessionStorage = new File($this->config->getSessionDir()); } } @@ -626,7 +635,8 @@ private function initSessionStorage() { * * @return resource */ - public function getStreamContext($timeout = 15) { + public function getStreamContext(int|float $timeout = 15) + { $opts = $this->config->getStreamContextOptions($timeout); $opts['http']['header'] = [ 'Authorization: Bearer ' . $this->getAccessToken(), @@ -642,15 +652,9 @@ public function getStreamContext($timeout = 15) { * @param BasicProjectInfo $project * @return bool */ - private function matchesVendorFilter($filters, BasicProjectInfo $project) + private function matchesVendorFilter(string|array|null $filters, BasicProjectInfo $project): bool { - if (empty($filters)) { - return true; - } - if (in_array($project->vendor, (array) $filters)) { - return true; - } - return false; + return empty($filters) || in_array($project->vendor, (array) $filters); } /** @@ -660,9 +664,10 @@ private function matchesVendorFilter($filters, BasicProjectInfo $project) * * @return BasicProjectInfo[] */ - public function getMyProjects($refresh = null) + public function getMyProjects(?bool $refresh = null): array { - $new = $this->config->get('api.centralized_permissions') && $this->config->get('api.organizations'); + $new = $this->config->getBool('api.centralized_permissions') && $this->config->getBool('api.organizations'); + /** @var string[]|string|null $vendorFilter */ $vendorFilter = $this->config->getWithDefault('api.vendor_filter', null); $cacheKey = $this->myProjectsCacheKey(); $cached = $this->cache->fetch($cacheKey); @@ -672,14 +677,14 @@ public function getMyProjects($refresh = null) } elseif ($refresh || !$cached) { $projects = []; if ($new) { - $this->debug('Loading extended access information to fetch the projects list'); + $this->io->debug('Loading extended access information to fetch the projects list'); foreach ($this->getClient()->getMyProjects() as $project) { if ($this->matchesVendorFilter($vendorFilter, $project)) { $projects[] = $project; } } } else { - $this->debug('Loading account information to fetch the projects list'); + $this->io->debug('Loading account information to fetch the projects list'); foreach ($this->getClient()->getProjectStubs((bool) $refresh) as $stub) { $project = BasicProjectInfo::fromStub($stub); if ($this->matchesVendorFilter($vendorFilter, $project)) { @@ -687,10 +692,10 @@ public function getMyProjects($refresh = null) } } } - $this->cache->save($cacheKey, $projects, (int) $this->config->getWithDefault('api.projects_ttl', 600)); + $this->cache->save($cacheKey, $projects, $this->config->getInt('api.projects_ttl')); } else { $projects = $cached; - $this->debug('Loaded user project data from cache'); + $this->io->debug('Loaded user project data from cache'); } return $projects; @@ -699,13 +704,13 @@ public function getMyProjects($refresh = null) /** * Return the user's project with the given ID. * - * @param string $id The project ID. + * @param string $id The project ID. * @param string|null $host The project's hostname. @deprecated no longer used if an api.base_url is configured. - * @param bool|null $refresh Whether to bypass the cache. + * @param bool|null $refresh Whether to bypass the cache. * * @return Project|false */ - public function getProject($id, $host = null, $refresh = null) + public function getProject(string $id, ?string $host = null, ?bool $refresh = null): Project|false { // Ignore the $host if an api.base_url is configured. $apiUrl = $this->config->getWithDefault('api.base_url', ''); @@ -727,7 +732,7 @@ public function getProject($id, $host = null, $refresh = null) if ($project) { $toCache = $project->getData(); $toCache['_endpoint'] = $project->getUri(true); - $this->cache->save($cacheKey, $toCache, (int) $this->config->getWithDefault('api.projects_ttl', 600)); + $this->cache->save($cacheKey, $toCache, $this->config->getInt('api.projects_ttl')); } else { return false; } @@ -736,7 +741,7 @@ public function getProject($id, $host = null, $refresh = null) $baseUrl = $cached['_endpoint']; unset($cached['_endpoint']); $project = new Project($cached, $baseUrl, $guzzleClient); - $this->debug('Loaded project from cache: ' . $id); + $this->io->debug('Loaded project from cache: ' . $id); } if ($apiUrl !== '') { $project->setApiUrl($apiUrl); @@ -752,9 +757,9 @@ public function getProject($id, $host = null, $refresh = null) * @param bool|null $refresh Whether to refresh the list. * @param bool $events Whether to update Drush aliases if the list changes. * - * @return array The user's environments, keyed by ID. + * @return array The project's environments, keyed by ID. */ - public function getEnvironments(Project $project, $refresh = null, $events = true) + public function getEnvironments(Project $project, ?bool $refresh = null, bool $events = true): array { $projectId = $project->id; @@ -770,7 +775,7 @@ public function getEnvironments(Project $project, $refresh = null, $events = tru } elseif ($refresh || !$cached) { // Fetch environments with double the default timeout. $list = Environment::getCollection($project->getLink('environments'), 0, [ - 'timeout' => 2 * $this->config->getWithDefault('api.default_timeout', 30), + 'timeout' => 2 * $this->config->getInt('api.default_timeout'), ], $this->getHttpClient()); $environments = []; $toCache = []; @@ -783,12 +788,12 @@ public function getEnvironments(Project $project, $refresh = null, $events = tru // Dispatch an event if the list of environments has changed. if ($events && (!$cached || array_diff_key($environments, $cached))) { $this->dispatcher->dispatch( + new EnvironmentsChangedEvent($project, $environments), 'environments_changed', - new EnvironmentsChangedEvent($project, $environments) ); } - $this->cache->save($cacheKey, $toCache, (int) $this->config->getWithDefault('api.environments_ttl', 120)); + $this->cache->save($cacheKey, $toCache, $this->config->getInt('api.environments_ttl')); } else { $environments = []; $endpoint = $project->getUri(); @@ -796,7 +801,7 @@ public function getEnvironments(Project $project, $refresh = null, $events = tru foreach ((array) $cached as $id => $data) { $environments[$id] = new Environment($data, $endpoint, $guzzleClient, true); } - $this->debug('Loaded environments from cache'); + $this->io->debug('Loaded environments from cache'); } self::$environmentsCache[$projectId] = $environments; @@ -815,7 +820,7 @@ public function getEnvironments(Project $project, $refresh = null, $events = tru * * @return Environment|false The environment, or false if not found. */ - public function getEnvironment($id, Project $project, $refresh = null, $tryMachineName = false) + public function getEnvironment(string $id, Project $project, ?bool $refresh = null, bool $tryMachineName = false): Environment|false { // Statically cache not found environments. $cacheKey = $project->id . ':' . $id . ($tryMachineName ? ':mn' : ''); @@ -823,7 +828,7 @@ public function getEnvironment($id, Project $project, $refresh = null, $tryMachi return false; } - $environmentsRefreshed = $refresh === true || ($refresh === null && empty(static::$environmentsCache[$project->id]) && !$this->cache->fetch('environments:' . $project->id)); + $environmentsRefreshed = $refresh === true || ($refresh === null && empty(self::$environmentsCache[$project->id]) && !$this->cache->fetch('environments:' . $project->id)); $environments = $this->getEnvironments($project, $refresh); // Look for the environment by ID. @@ -862,7 +867,7 @@ public function getEnvironment($id, Project $project, $refresh = null, $tryMachi * * @return EnvironmentType[] */ - public function getEnvironmentTypes(Project $project, $refresh = null) + public function getEnvironmentTypes(Project $project, ?bool $refresh = null): array { $cacheKey = sprintf('environment-types:%s', $project->id); @@ -875,16 +880,14 @@ public function getEnvironmentTypes(Project $project, $refresh = null) return []; } elseif ($refresh || !$cached) { $types = $project->getEnvironmentTypes(); - $cachedTypes = \array_map(function (EnvironmentType $type) { - return $type->getData() + ['_uri' => $type->getUri()]; - }, $types); - $this->cache->save($cacheKey, $cachedTypes, (int) $this->config->getWithDefault('api.environments_ttl', 120)); + $cachedTypes = \array_map(fn(EnvironmentType $type) => $type->getData() + ['_uri' => $type->getUri()], $types); + $this->cache->save($cacheKey, $cachedTypes, $this->config->getInt('api.environments_ttl')); } else { $guzzleClient = $this->getHttpClient(); foreach ((array) $cached as $data) { $types[] = new EnvironmentType($data, $data['_uri'], $guzzleClient); } - $this->debug('Loaded environment types from cache for project: ' . $project->id); + $this->io->debug('Loaded environment types from cache for project: ' . $project->id); } return $types; @@ -896,16 +899,16 @@ public function getEnvironmentTypes(Project $project, $refresh = null) * @param bool $reset * * @return array{ - * 'id': string, - * 'username': string, - * 'email': string, - * 'first_name': string, - * 'last_name': string, - * 'display_name': string, - * 'phone_number_verified': bool, + * id: string, + * username: string, + * email: string, + * first_name: string, + * last_name: string, + * display_name: string, + * phone_number_verified: bool, * } */ - public function getMyAccount($reset = false) + public function getMyAccount(bool $reset = false): array { $user = $this->getUser(null, $reset); return $user->getProperties() + [ @@ -918,17 +921,17 @@ public function getMyAccount($reset = false) * * @param bool $reset * - * @return array{'id': string, 'username': string, 'mail': string, 'display_name': string, 'ssh_keys': array} + * @return array{'id': string, 'username': string, 'mail': string, 'display_name': string, 'ssh_keys': array} */ - private function getLegacyAccountInfo($reset = false) + private function getLegacyAccountInfo(bool $reset = false): array { $cacheKey = sprintf('%s:my-account', $this->config->getSessionId()); $info = $this->cache->fetch($cacheKey); if (!$reset && $info) { - $this->debug('Loaded account information from cache'); + $this->io->debug('Loaded account information from cache'); } else { $info = $this->getClient()->getAccountInfo($reset); - $this->cache->save($cacheKey, $info, (int) $this->config->getWithDefault('api.users_ttl', 600)); + $this->cache->save($cacheKey, $info, $this->config->getInt('api.users_ttl')); } return $info; @@ -936,12 +939,14 @@ private function getLegacyAccountInfo($reset = false) /** * Shortcut to return the ID of the current user. - * - * @return string|false */ - public function getMyUserId($reset = false) + public function getMyUserId(bool $reset = false): string { - return $this->getClient()->getMyUserId($reset); + $id = $this->getClient()->getMyUserId($reset); + if (!$id) { + throw new \RuntimeException('No user ID found for the current session.'); + } + return $id; } /** @@ -951,7 +956,7 @@ public function getMyUserId($reset = false) * * @return SshKey[] */ - public function getSshKeys($reset = false) + public function getSshKeys(bool $reset = false): array { $data = $this->getLegacyAccountInfo($reset); @@ -969,7 +974,7 @@ public function getSshKeys($reset = false) * * @return User */ - public function getUser($id = null, $reset = false) + public function getUser(?string $id = null, bool $reset = false): User { if ($id) { $cacheKey = 'user:' . $id; @@ -982,9 +987,9 @@ public function getUser($id = null, $reset = false) if (!$user) { throw new \InvalidArgumentException('User not found: ' . $id); } - $this->cache->save($cacheKey, $user->getData(), (int) $this->config->getWithDefault('api.users_ttl', 600)); + $this->cache->save($cacheKey, $user->getData(), $this->config->getInt('api.users_ttl')); } else { - $this->debug('Loaded user info from cache: ' . $id); + $this->io->debug('Loaded user info from cache: ' . $id); $connector = $this->getClient()->getConnector(); $user = new User($data, $connector->getApiUrl() . '/users', $connector->getClient()); } @@ -998,12 +1003,12 @@ public function getUser($id = null, $reset = false) * * @param string $projectId */ - public function clearEnvironmentsCache($projectId) + public function clearEnvironmentsCache(string $projectId): void { $this->cache->delete('environments:' . $projectId); unset(self::$environmentsCache[$projectId]); foreach (array_keys(self::$notFound) as $key) { - if (strpos($key, $projectId . ':') === 0) { + if (str_starts_with($key, $projectId . ':')) { unset(self::$notFound[$key]); } } @@ -1014,9 +1019,10 @@ public function clearEnvironmentsCache($projectId) * * @return string */ - private function myProjectsCacheKey() + private function myProjectsCacheKey(): string { - $new = $this->config->get('api.centralized_permissions') && $this->config->get('api.organizations'); + $new = $this->config->getBool('api.centralized_permissions') && $this->config->getBool('api.organizations'); + /** @var string[]|string|null $vendorFilter */ $vendorFilter = $this->config->getWithDefault('api.vendor_filter', null); return sprintf('%s:my-projects%s:%s', $this->config->getSessionId(), $new ? ':new' : '', is_array($vendorFilter) ? implode(',', $vendorFilter) : (string) $vendorFilter); } @@ -1024,7 +1030,7 @@ private function myProjectsCacheKey() /** * Clears the projects cache. */ - public function clearProjectsCache() + public function clearProjectsCache(): void { $this->cache->delete($this->myProjectsCacheKey()); } @@ -1040,15 +1046,13 @@ public function clearProjectsCache() * * @return void */ - public static function sortResources(array &$resources, $propertyPath, $reverse = false) + public static function sortResources(array &$resources, string $propertyPath, bool $reverse = false): void { - uasort($resources, function (ApiResource $a, ApiResource $b) use ($propertyPath, $reverse) { - return Sort::compare( - static::getNestedProperty($a, $propertyPath, false), - static::getNestedProperty($b, $propertyPath, false), - $reverse - ); - }); + uasort($resources, fn(ApiResource $a, ApiResource $b) => Sort::compare( + static::getNestedProperty($a, $propertyPath, false), + static::getNestedProperty($b, $propertyPath, false), + $reverse, + )); } /** @@ -1062,7 +1066,7 @@ public static function sortResources(array &$resources, $propertyPath, $reverse * * @return mixed */ - public static function getNestedProperty(ApiResource $resource, $propertyPath, $lazyLoad = true) + public static function getNestedProperty(ApiResource $resource, string $propertyPath, bool $lazyLoad = true): mixed { if (!strpos($propertyPath, '.')) { return $resource->getProperty($propertyPath, true, $lazyLoad); @@ -1075,7 +1079,7 @@ public static function getNestedProperty(ApiResource $resource, $propertyPath, $ throw new \InvalidArgumentException(sprintf( 'Invalid path "%s": the property "%s" is not an array.', $propertyPath, - $propertyName + $propertyName, )); } $value = NestedArrayUtil::getNestedArrayValue($property, $parents, $keyExists); @@ -1089,7 +1093,7 @@ public static function getNestedProperty(ApiResource $resource, $propertyPath, $ /** * @return bool */ - public function isLoggedIn() + public function isLoggedIn(): bool { return $this->getClient(false)->getConnector()->isLoggedIn(); } @@ -1102,8 +1106,10 @@ public function isLoggedIn() * * @return string */ - public function getProjectLabel($project, $tag = 'info') - { + public function getProjectLabel( + Project|BasicProjectInfo|\Platformsh\Client\Model\Organization\Project|TeamProjectAccess|string $project, + string|false $tag = 'info', + ): string { static $titleCache = []; if ($project instanceof Project || $project instanceof BasicProjectInfo || $project instanceof \Platformsh\Client\Model\Organization\Project) { $title = $project->title; @@ -1113,7 +1119,7 @@ public function getProjectLabel($project, $tag = 'info') $title = $project->project_title; $id = $project->project_id; $titleCache[$id] = $title; - } elseif (is_string($project)) { + } else { if (isset($titleCache[$project])) { $title = $titleCache[$project]; $id = $project; @@ -1126,8 +1132,6 @@ public function getProjectLabel($project, $tag = 'info') $id = $projectObj->id; $titleCache[$id] = $title; } - } else { - throw new \InvalidArgumentException('Invalid type for $project'); } $pattern = strlen($title) > 0 ? '%2$s (%3$s)' : '%3$s'; if ($tag !== false) { @@ -1139,14 +1143,8 @@ public function getProjectLabel($project, $tag = 'info') /** * Returns an environment label. - * - * @param Environment $environment - * @param string|false $tag - * @param bool $showType - * - * @return string */ - public function getEnvironmentLabel(Environment $environment, $tag = 'info', $showType = true) + public function getEnvironmentLabel(Environment $environment, string|false $tag = 'info', bool $showType = true): string { $id = $environment->id; $title = $environment->title; @@ -1166,13 +1164,8 @@ public function getEnvironmentLabel(Environment $environment, $tag = 'info', $sh /** * Returns an organization label. - * - * @param Organization $organization - * @param string|false $tag - * - * @return string */ - public function getOrganizationLabel(Organization $organization, $tag = 'info') + public function getOrganizationLabel(Organization $organization, string|false $tag = 'info'): string { $name = $organization->name; $label = $organization->label; @@ -1195,21 +1188,17 @@ public function getOrganizationLabel(Organization $organization, $tag = 'info') * @return ApiResource * The resource, if one (and only one) is matched. */ - public function matchPartialId($id, array $resources, $name = 'Resource') + public function matchPartialId(string $id, array $resources, string $name = 'Resource'): ApiResource { - $matched = array_filter($resources, function (ApiResource $resource) use ($id) { - return strpos($resource->getProperty('id'), $id) === 0; - }); + $matched = array_filter($resources, fn(ApiResource $resource): bool => str_starts_with((string) $resource->getProperty('id'), $id)); if (count($matched) > 1) { - $matchedIds = array_map(function (ApiResource $resource) { - return $resource->getProperty('id'); - }, $matched); + $matchedIds = array_map(fn(ApiResource $resource): mixed => $resource->getProperty('id'), $matched); throw new \InvalidArgumentException(sprintf( 'The partial ID "%s" is ambiguous; it matches the following %s IDs: %s', $id, strtolower($name), - "\n " . implode("\n ", $matchedIds) + "\n " . implode("\n ", $matchedIds), )); } elseif (count($matched) === 0) { throw new \InvalidArgumentException(sprintf('%s not found: "%s"', $name, $id)); @@ -1223,7 +1212,7 @@ public function matchPartialId($id, array $resources, $name = 'Resource') * * @return string */ - public function getAccessToken() + public function getAccessToken(): string { // Check for an externally configured access token. if ($accessToken = $this->tokenConfig->getAccessToken()) { @@ -1257,7 +1246,7 @@ public function getAccessToken() * * @return ClientInterface */ - public function getHttpClient() + public function getHttpClient(): ClientInterface { return $this->getClient()->getConnector()->getClient(); } @@ -1269,22 +1258,15 @@ public function getHttpClient() * * @return ClientInterface */ - public function getExternalHttpClient() + public function getExternalHttpClient(): ClientInterface { return new Client($this->getGuzzleOptions()); } /** * Get the current deployment for an environment. - * - * @param Environment $environment - * @param bool $refresh - * @param bool $required - * - * @return EnvironmentDeployment|false - * The current deployment, or false if $required is false and there is no current deployment. */ - public function getCurrentDeployment(Environment $environment, $refresh = false, $required = true) + public function getCurrentDeployment(Environment $environment, bool $refresh = false): EnvironmentDeployment { $cacheKey = implode(':', ['current-deployment', $environment->project, $environment->id, $environment->head_commit]); if (!$refresh && isset(self::$deploymentsCache[$cacheKey])) { @@ -1293,21 +1275,19 @@ public function getCurrentDeployment(Environment $environment, $refresh = false, $data = $this->cache->fetch($cacheKey); if ($data === false || $refresh) { try { - $deployment = $environment->getCurrentDeployment($required); + /** @var EnvironmentDeployment $deployment */ + $deployment = $environment->getCurrentDeployment(); } catch (EnvironmentStateException $e) { if ($e->getEnvironment()->status === 'inactive') { throw new EnvironmentStateException('The environment is inactive', $e->getEnvironment()); } throw $e; } - if (!$required && $deployment === false) { - return self::$deploymentsCache[$cacheKey] = false; - } $data = $deployment->getData(); $data['_uri'] = $deployment->getUri(); $this->cache->save($cacheKey, $data); } else { - $this->debug('Loaded environment deployment from cache for environment: ' . $environment->id); + $this->io->debug('Loaded environment deployment from cache for environment: ' . $environment->id); $deployment = new EnvironmentDeployment($data, $data['_uri'], $this->getHttpClient(), true); } @@ -1321,7 +1301,7 @@ public function getCurrentDeployment(Environment $environment, $refresh = false, * * @return bool */ - public function hasCachedCurrentDeployment(Environment $environment) + public function hasCachedCurrentDeployment(Environment $environment): bool { $cacheKey = implode(':', ['current-deployment', $environment->project, $environment->id, $environment->head_commit]); @@ -1340,7 +1320,7 @@ public function hasCachedCurrentDeployment(Environment $environment) * * @return Environment|null */ - public function getDefaultEnvironment(array $envs, Project $project, $onlyDefaultBranch = false) + public function getDefaultEnvironment(array $envs, Project $project, bool $onlyDefaultBranch = false): ?Environment { if ($project->default_branch === '') { throw new \RuntimeException('Default branch not set'); @@ -1360,17 +1340,13 @@ public function getDefaultEnvironment(array $envs, Project $project, $onlyDefaul } // Check if there is only one "production" environment. - $prod = \array_filter($envs, function (Environment $environment) { - return $environment->type === 'production'; - }); + $prod = \array_filter($envs, fn(Environment $environment): bool => $environment->type === 'production'); if (\count($prod) === 1) { return \reset($prod); } // Check if there is only one "main" environment. - $main = \array_filter($envs, function (Environment $environment) { - return $environment->is_main; - }); + $main = \array_filter($envs, fn(Environment $environment) => $environment->is_main); if (\count($main) === 1) { return \reset($main); } @@ -1381,22 +1357,20 @@ public function getDefaultEnvironment(array $envs, Project $project, $onlyDefaul /** * Get the preferred site URL for an environment and app. * - * @param \Platformsh\Client\Model\Environment $environment - * @param string $appName - * @param \Platformsh\Client\Model\Deployment\EnvironmentDeployment|null $deployment + * @param Environment $environment + * @param string $appName + * @param EnvironmentDeployment|null $deployment * * @return string|null */ - public function getSiteUrl(Environment $environment, $appName, EnvironmentDeployment $deployment = null) + public function getSiteUrl(Environment $environment, string $appName, ?EnvironmentDeployment $deployment = null): ?string { $deployment = $deployment ?: $this->getCurrentDeployment($environment); $routes = Route::fromDeploymentApi($deployment->routes); // Return the first route that matches this app. // The routes will already have been sorted. - $routes = \array_filter($routes, function (Route $route) use ($appName) { - return $route->type === 'upstream' && $route->getUpstreamName() === $appName; - }); + $routes = \array_filter($routes, fn(Route $route): bool => $route->type === 'upstream' && $route->getUpstreamName() === $appName); $route = reset($routes); if ($route) { return $route->url; @@ -1412,14 +1386,11 @@ public function getSiteUrl(Environment $environment, $appName, EnvironmentDeploy /** * React on an API 403 request. - * - * @param \GuzzleHttp\Event\ErrorEvent $event */ - private function on403(ErrorEvent $event) + private function on403(RequestInterface $request): void { - $url = $event->getRequest()->getUrl(); - $path = parse_url($url, PHP_URL_PATH); - if ($path && strpos($path, '/api/projects/') === 0) { + $path = $request->getUri()->getPath(); + if (str_starts_with($path, '/api/projects/')) { // Clear the environments cache for environment request errors. if (preg_match('#^/api/projects/([^/]+?)/environments/#', $path, $matches)) { $this->clearEnvironmentsCache($matches[1]); @@ -1438,14 +1409,14 @@ private function on403(ErrorEvent $event) * @param Project|null $project * @param bool $forWrite * - * @throws \GuzzleHttp\Exception\RequestException + * @throws RequestException * * @return false|Subscription * The subscription or false if not found. */ - public function loadSubscription($id, Project $project = null, $forWrite = true) + public function loadSubscription(string $id, ?Project $project = null, bool $forWrite = true): Subscription|false { - $organizations_enabled = $this->config->getWithDefault('api.organizations', false); + $organizations_enabled = $this->config->getBool('api.organizations'); if (!$organizations_enabled) { // Always load the subscription directly if the Organizations API // is not enabled. @@ -1460,13 +1431,13 @@ public function loadSubscription($id, Project $project = null, $forWrite = true) try { $subscription = $this->getClient()->getSubscription($id); } catch (BadResponseException $e) { - if (!$e->getResponse() || $e->getResponse()->getStatusCode() !== 403) { + if ($e->getResponse()->getStatusCode() !== 403) { throw $e; } $subscription = false; } if ($subscription) { - $this->debug('Loaded the subscription directly'); + $this->io->debug('Loaded the subscription directly'); return $subscription; } } @@ -1477,24 +1448,24 @@ public function loadSubscription($id, Project $project = null, $forWrite = true) $organizationId = $project->getProperty('organization', false, false); } else { foreach ($this->getMyProjects() as $info) { - if ($info->subscription_id === $id && (!isset($project) || $project->id === $info->id)) { + if ($info->subscription_id === $id) { $organizationId = !empty($info->organization_ref->id) ? $info->organization_ref->id : false; break; } } } if (empty($organizationId)) { - $this->debug('Failed to find the organization ID for the subscription: ' . $id); + $this->io->debug('Failed to find the organization ID for the subscription: ' . $id); return false; } $organization = $this->getOrganizationById($organizationId); if (!$organization) { - $this->debug('Project organization not found: ' . $organizationId); + $this->io->debug('Project organization not found: ' . $organizationId); return false; } $subscription = $organization->getSubscription($id); if (!$subscription) { - $this->debug('Failed to load subscription: ' . $id); + $this->io->debug('Failed to load subscription: ' . $id); return false; } @@ -1504,37 +1475,36 @@ public function loadSubscription($id, Project $project = null, $forWrite = true) /** * Returns whether the user is required to verify their phone number before certain actions. * - * @return array{'state': bool, 'type': string} + * @return array{state: bool, type: string} */ - public function checkUserVerification() + public function checkUserVerification(): array { - if (!$this->config->getWithDefault('api.user_verification', false)) { + if (!$this->config->getBool('api.user_verification')) { return ['state' => false, 'type' => '']; } // Check the API to see if verification is required. - return $this->getHttpClient()->post( '/me/verification')->json(); + $request = new Request('POST', '/me/verification'); + $response = $this->getHttpClient()->send($request); + return (array) Utils::jsonDecode((string) $response->getBody(), true); } /** * Returns whether the user is allowed to create a project under an organization. * - * @return array{'can_create': bool, 'message': string, 'required_action': ?array{'action': string, 'type': string}} + * @return array{can_create: bool, message: string, required_action: ?array{action: string, type: string}} */ - public function checkCanCreate(Organization $org) + public function checkCanCreate(Organization $org): array { - return $this->getHttpClient()->get( $org->getUri() . '/subscriptions/can-create')->json(); + $request = new Request('GET', $org->getUri() . '/subscriptions/can-create'); + $response = $this->getHttpClient()->send($request); + return (array) Utils::jsonDecode((string) $response->getBody(), true); } /** * Returns a descriptive label for a referenced user. - * - * @param UserRef $userRef - * @param string|false $tag - * - * @return string */ - public function getUserRefLabel(UserRef $userRef, $tag = 'info') + public function getUserRefLabel(UserRef $userRef, string|false $tag = 'info'): string { $name = trim($userRef->first_name . ' ' . $userRef->last_name); $pattern = $name !== '' ? '%2$s \<%3$s>' : '%3$s'; @@ -1546,35 +1516,27 @@ public function getUserRefLabel(UserRef $userRef, $tag = 'info') /** * Loads an organization by ID, with caching. - * - * @param string $id - * @param bool $reset - * @return Organization|false */ - public function getOrganizationById($id, $reset = false) + public function getOrganizationById(string $id, bool $reset = false): Organization|false { $cacheKey = 'organization:' . $id; if (!$reset && ($cached = $this->cache->fetch($cacheKey))) { - $this->debug('Loaded organization from cache: ' . $id); + $this->io->debug('Loaded organization from cache: ' . $id); return new Organization($cached, $cached['_url'], $this->getHttpClient()); } $organization = $this->getClient()->getOrganizationById($id); if ($organization) { $data = $organization->getData(); $data['_url'] = $organization->getUri(); - $this->cache->save($cacheKey, $data, $this->config->getWithDefault('api.orgs_ttl', 600)); + $this->cache->save($cacheKey, $data, $this->config->getInt('api.orgs_ttl')); } return $organization; } /** * Loads an organization by name, with caching. - * - * @param string $name - * @param bool $reset - * @return Organization|false */ - public function getOrganizationByName($name, $reset = false) + public function getOrganizationByName(string $name, bool $reset = false): Organization|false { return $this->getOrganizationById('name=' . $name, $reset); } @@ -1585,7 +1547,7 @@ public function getOrganizationByName($name, $reset = false) * @param Organization $org * @return void */ - public function clearOrganizationCache(Organization $org) + public function clearOrganizationCache(Organization $org): void { $this->cache->delete('organization:' . $org->id); $this->cache->delete('organization:name=' . $org->name); @@ -1593,15 +1555,10 @@ public function clearOrganizationCache(Organization $org) /** * Returns the Console URL for a project, with caching. - * - * @param Project $project - * @param bool $reset - * - * @return false|string */ - public function getConsoleURL(Project $project, $reset = false) + public function getConsoleURL(Project $project, bool $reset = false): string { - if ($this->config->has('service.console_url') && $this->config->get('api.organizations')) { + if ($this->config->has('service.console_url') && $this->config->getBool('api.organizations')) { // Load the organization name if possible. $firstSegment = $organizationId = $project->getProperty('organization'); try { @@ -1610,29 +1567,26 @@ public function getConsoleURL(Project $project, $reset = false) $firstSegment = $organization->name; } } catch (BadResponseException $e) { - if ($e->getResponse() && $e->getResponse()->getStatusCode() === 403) { + if ($e->getResponse()->getStatusCode() === 403) { trigger_error($e->getMessage(), E_USER_WARNING); } else { throw $e; } } - return ltrim($this->config->get('service.console_url'), '/') . '/' . rawurlencode($firstSegment) . '/' . rawurlencode($project->id); + return ltrim($this->config->getStr('service.console_url'), '/') . '/' . rawurlencode((string) $firstSegment) . '/' . rawurlencode($project->id); + } elseif ($subscription = $this->loadSubscription((string) $project->getSubscriptionId(), $project)) { + return $subscription->project_ui; } - $subscription = $this->loadSubscription($project->getSubscriptionId(), $project); - return $subscription ? $subscription->project_ui : false; + throw new \RuntimeException('Failed to load Console URL for project: ' . $project->id); } /** * Loads an organization member by email, by paging through all the members in the organization. * * @TODO replace this with a more efficient API when available - * - * @param Organization $organization - * @param string $email - * @return Member|null */ - public function loadMemberByEmail(Organization $organization, $email) + public function loadMemberByEmail(Organization $organization, string $email): ?Member { foreach ($this->listMembers($organization) as $member) { if ($member->getUserInfo() && strcasecmp($member->getUserInfo()->email, $email) === 0) { @@ -1645,10 +1599,9 @@ public function loadMemberByEmail(Organization $organization, $email) /** * Loads organization members (with static caching). * - * @param bool $reset * @return Member[] */ - public function listMembers(Organization $organization, $reset = false) + public function listMembers(Organization $organization, bool $reset = false): array { static $cache = []; $cacheKey = $organization->id; @@ -1669,11 +1622,8 @@ public function listMembers(Organization $organization, $reset = false) /** * Returns a label for an organization or team member. - * - * @param Member|TeamMember $member - * @return string */ - public function getMemberLabel($member) + public function getMemberLabel(Member|TeamMember $member): string { if ($userInfo = $member->getUserInfo()) { $label = sprintf('%s (%s)', trim($userInfo->first_name . ' ' . $userInfo->last_name), $userInfo->email); @@ -1690,7 +1640,7 @@ public function getMemberLabel($member) * @param EnvironmentDeployment|null $deployment * @return bool */ - public function supportsSizingApi(Project $project, EnvironmentDeployment $deployment = null) + public function supportsSizingApi(Project $project, ?EnvironmentDeployment $deployment = null): bool { if (isset($deployment->project_info['settings'])) { return !empty($deployment->project_info['settings']['sizing_api_enabled']); @@ -1700,8 +1650,10 @@ public function supportsSizingApi(Project $project, EnvironmentDeployment $deplo if (!empty($cachedSettings['sizing_api_enabled'])) { return true; } - $settings = $this->getHttpClient()->get($project->getUri() . '/settings')->json(); - $this->cache->save($cacheKey, $settings, $this->config->get('api.projects_ttl')); + $request = new Request('GET', $project->getUri() . '/settings'); + $response = $this->getHttpClient()->send($request); + $settings = (array) Utils::jsonDecode((string) $response->getBody(), true); + $this->cache->save($cacheKey, $settings, $this->config->getInt('api.projects_ttl')); return !empty($settings['sizing_api_enabled']); } @@ -1709,9 +1661,9 @@ public function supportsSizingApi(Project $project, EnvironmentDeployment $deplo * Loads the code source integration for a project. * * @param Project $project - * @return \Platformsh\Client\Model\Integration|null + * @return Integration|null */ - public function getCodeSourceIntegration(Project $project) + public function getCodeSourceIntegration(Project $project): ?Integration { $codeSourceIntegrationTypes = ['github', 'gitlab', 'bitbucket', 'bitbucket_server']; foreach ($project->getIntegrations() as $integration) { @@ -1721,4 +1673,75 @@ public function getCodeSourceIntegration(Project $project) } return null; } + + /** + * Shows information about the currently logged in user and their session, if applicable. + * + * @param bool $logout Whether this should avoid re-authentication (if an API token is set). + * @param bool $newline Whether to prepend a newline if there is output. + */ + public function showSessionInfo(bool $logout = false, bool $newline = true): void + { + $sessionId = $this->config->getSessionId(); + if ($sessionId !== 'default' || count($this->listSessionIds()) > 1) { + if ($newline) { + $this->stdErr->writeln(''); + $newline = false; + } + $this->stdErr->writeln(sprintf('The current session ID is: %s', $sessionId)); + if (!$this->config->isSessionIdFromEnv()) { + $this->stdErr->writeln(sprintf('Change this using: %s session:switch', $this->config->getStr('application.executable'))); + } + } + if (!$logout && $this->isLoggedIn()) { + if ($newline) { + $this->stdErr->writeln(''); + } + $account = $this->getMyAccount(); + $this->stdErr->writeln(\sprintf( + 'You are logged in as %s (%s)', + $account['username'], + $account['email'], + )); + } + } + + /** + * Warn the user if a project is suspended. + * + * @param Project $project + */ + public function warnIfSuspended(Project $project): void + { + if ($project->isSuspended()) { + $this->stdErr->writeln('This project is suspended.'); + if ($this->config->getBool('warnings.project_suspended_payment')) { + $orgId = $project->getProperty('organization', false); + if ($orgId) { + try { + $organization = $this->getClient()->getOrganizationById($orgId); + } catch (BadResponseException) { + $organization = false; + } + if ($organization && $organization->hasLink('payment-source')) { + $this->stdErr->writeln(sprintf('To re-activate it, update the payment details for your organization, %s.', $this->getOrganizationLabel($organization, 'comment'))); + } + } elseif ($project->owner === $this->getMyUserId()) { + $this->stdErr->writeln('To re-activate it, update your payment details.'); + } + } + } + } + + /** + * Warn the user that the remote environment needs redeploying. + */ + public function redeployWarning(): void + { + $this->stdErr->writeln([ + '', + 'The remote environment(s) must be redeployed for the change to take effect.', + 'To redeploy an environment, run: ' . $this->config->getStr('application.executable') . ' redeploy', + ]); + } } diff --git a/src/Service/AutoLoginListener.php b/src/Service/AutoLoginListener.php new file mode 100644 index 0000000000..1ddb20f283 --- /dev/null +++ b/src/Service/AutoLoginListener.php @@ -0,0 +1,67 @@ +stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; + } + + /** + * Log in the user. + * + * @see Api::getClient() + */ + public function onLoginRequired(LoginRequiredEvent $event): void + { + $success = false; + if ($this->input->isInteractive()) { + $sessionAdvice = []; + if ($this->config->getSessionId() !== 'default' || count($this->api->listSessionIds()) > 1) { + $sessionAdvice[] = sprintf('The current session ID is: %s', $this->config->getSessionId()); + if (!$this->config->isSessionIdFromEnv()) { + $sessionAdvice[] = sprintf('To switch sessions, run: %s session:switch', $this->config->getStr('application.executable')); + } + } + + if ($this->urlService->canOpenUrls()) { + $this->stdErr->writeln($event->getMessage()); + $this->stdErr->writeln(''); + if ($sessionAdvice) { + $this->stdErr->writeln($sessionAdvice); + $this->stdErr->writeln(''); + } + if ($this->questionHelper->confirm('Log in via a browser?')) { + $this->stdErr->writeln(''); + $exitCode = $this->commandDispatcher->run('auth:browser-login', $event->getLoginOptions()); + $this->stdErr->writeln(''); + $success = $exitCode === 0; + } + } + } + if (!$success) { + $e = new LoginRequiredException(); + $e->setMessageFromEvent($event); + throw $e; + } + } +} diff --git a/src/Service/CacheFactory.php b/src/Service/CacheFactory.php index 8d29f4205b..925e9d90ac 100644 --- a/src/Service/CacheFactory.php +++ b/src/Service/CacheFactory.php @@ -1,28 +1,30 @@ getWithDefault('api.disable_cache', false)) { + if ($cliConfig->getBool('api.disable_cache')) { return new VoidCache(); } return new FilesystemCache( $cliConfig->getWritableUserDir() . '/cache', FilesystemCache::EXTENSION, - 0077 // Remove all permissions from the group and others. + 0o077, // Remove all permissions from the group and others. ); } } diff --git a/src/Service/Config.php b/src/Service/Config.php index 372c8ed917..93796149d3 100644 --- a/src/Service/Config.php +++ b/src/Service/Config.php @@ -1,7 +1,10 @@ */ + private array $config; + private string $configFile; + + /** @var array */ + private array $env; + + private ?Filesystem $fs = null; + private ?string $version = null; + private ?string $homeDir = null; /** - * @param array|null $env + * @param array|null $env * @param string|null $file */ - public function __construct(array $env = null, $file = null) + public function __construct(?array $env = null, ?string $file = null) { - $this->env = $env !== null ? $env : $this->getDefaultEnv(); + $this->env = $env !== null ? $env : getenv(); if ($file === null) { $file = $this->getEnv('CLI_CONFIG_FILE', false) ?: CLI_ROOT . '/config.yaml'; @@ -51,7 +58,7 @@ public function __construct(array $env = null, $file = null) if ($id !== false) { try { $this->validateSessionId(\trim($id)); - } catch (\InvalidArgumentException $e) { + } catch (\InvalidArgumentException) { throw new \InvalidArgumentException('Invalid session ID in file: ' . $sessionIdFile); } $this->config['api']['session_id'] = \trim($id); @@ -65,16 +72,6 @@ public function __construct(array $env = null, $file = null) } } - /** - * Find all current environment variables. - * - * @return array - */ - private function getDefaultEnv() - { - return PHP_VERSION_ID >= 70100 ? getenv() : $_ENV; - } - /** * Check if a configuration value is defined. * @@ -84,7 +81,7 @@ private function getDefaultEnv() * * @return bool */ - public function has($name, $notNull = true) + public function has(string $name, bool $notNull = true): bool { $value = NestedArrayUtil::getNestedArrayValue($this->config, explode('.', $name), $exists); @@ -98,9 +95,9 @@ public function has($name, $notNull = true) * * @throws \RuntimeException if the configuration is not defined. * - * @return null|string|bool|array + * @return null|string|bool|array|int|float */ - public function get($name) + public function get(string $name): bool|array|string|null|int|float { $value = NestedArrayUtil::getNestedArrayValue($this->config, explode('.', $name), $exists); if (!$exists) { @@ -110,6 +107,66 @@ public function get($name) return $value; } + /** + * Get a string configuration value. + * + * @param string $name The configuration name (e.g. 'application.name'). + * + * @throws \RuntimeException if the configuration is not defined or not a string. + * + * @return string + */ + public function getStr(string $name): string + { + $value = $this->get($name); + if (!is_string($value)) { + if ($value === null) { + return ''; + } + throw new \RuntimeException(sprintf('Configuration %s was expected to be a string, %s found', $name, gettype($value))); + } + + return $value; + } + + /** + * Get an integer configuration value. + * + * @param string $name The configuration name (e.g. 'api.default_timeout'). + * + * @throws \RuntimeException if the configuration is not defined or not an integer. + * + * @return int + */ + public function getInt(string $name): int + { + $value = $this->get($name); + if (!is_int($value) && (!is_string($value) || (int) $value != $value)) { + throw new \RuntimeException(sprintf('Configuration %s was expected to be an integer, %s found', $name, gettype($value))); + } + + return (int) $value; + } + + /** + * Get a Boolean configuration value. + * + * @param string $name The configuration name (e.g. 'api.sizing'). + * + * @throws \RuntimeException if the configuration is not defined or not a Boolean, 1 or 0. + * + * @return bool + */ + public function getBool(string $name): bool + { + $value = $this->get($name); + if ((bool) $value != $value) { + throw new \RuntimeException(sprintf('Configuration %s of type %s could not be cast to true or false.', $name, gettype($value))); + } + + return (bool) $value; + } + /** * Get a configuration value, specifying a default if it does not exist. * @@ -121,7 +178,7 @@ public function get($name) * * @return mixed */ - public function getWithDefault($name, $default, $useDefaultIfNull = true) + public function getWithDefault(string $name, mixed $default, bool $useDefaultIfNull = true): mixed { $value = NestedArrayUtil::getNestedArrayValue($this->config, explode('.', $name), $exists); if (!$exists || ($useDefaultIfNull && $value === null)) { @@ -138,12 +195,12 @@ public function getWithDefault($name, $default, $useDefaultIfNull = true) * * @return string The absolute path to the user's home directory */ - public function getHomeDirectory($reset = false) + public function getHomeDirectory(bool $reset = false): string { if (!$reset && isset($this->homeDir)) { return $this->homeDir; } - $prefix = isset($this->config['application']['env_prefix']) ? $this->config['application']['env_prefix'] : ''; + $prefix = $this->config['application']['env_prefix'] ?? ''; $envVars = [$prefix . 'HOME', 'HOME', 'USERPROFILE']; foreach ($envVars as $envVar) { $value = getenv($envVar); @@ -153,7 +210,7 @@ public function getHomeDirectory($reset = false) if (is_string($value) && $value !== '') { if (!is_dir($value)) { throw new \RuntimeException( - sprintf('Invalid environment variable %s: %s (not a directory)', $envVar, $value) + sprintf('Invalid environment variable %s: %s (not a directory)', $envVar, $value), ); } $this->homeDir = realpath($value) ?: $value; @@ -172,19 +229,19 @@ public function getHomeDirectory($reset = false) * * @return string */ - public function getUserConfigDir($absolute = true) + public function getUserConfigDir(bool $absolute = true): string { - $path = $this->get('application.user_config_dir'); + $path = $this->getStr('application.user_config_dir'); return $absolute ? $this->getHomeDirectory() . DIRECTORY_SEPARATOR . $path : $path; } - /** - * @return \Platformsh\Cli\Service\Filesystem - */ - private function fs() + private function fs(): Filesystem { - return $this->fs ?: new Filesystem(); + if (!isset($this->fs)) { + $this->fs = new Filesystem(); + } + return $this->fs; } /** @@ -194,28 +251,21 @@ private function fs() * * @return string */ - public function getWritableUserDir() + public function getWritableUserDir(): string { - $path = isset($this->config['application']['writable_user_dir']) - ? $this->config['application']['writable_user_dir'] - : $this->getUserConfigDir(false); + $path = $this->config['application']['writable_user_dir'] ?? $this->getUserConfigDir(false); $configDir = $this->getHomeDirectory() . DIRECTORY_SEPARATOR . $path; // If the directory is not writable (e.g. if we are on a Platform.sh // environment), use a temporary directory instead. if (!$this->fs()->canWrite($configDir) || (file_exists($configDir) && !is_dir($configDir))) { - return sys_get_temp_dir() . DIRECTORY_SEPARATOR . $this->get('application.tmp_sub_dir'); + return sys_get_temp_dir() . DIRECTORY_SEPARATOR . $this->getStr('application.tmp_sub_dir'); } return $configDir; } - /** - * @param bool $subDir - * - * @return string - */ - public function getSessionDir($subDir = false) + public function getSessionDir(bool $subDir = false): string { $sessionDir = $this->getWritableUserDir() . DIRECTORY_SEPARATOR . '.session'; if ($subDir) { @@ -228,7 +278,7 @@ public function getSessionDir($subDir = false) /** * @return string */ - public function getSessionId() + public function getSessionId(): string { return $this->getWithDefault('api.session_id', 'default'); } @@ -238,18 +288,15 @@ public function getSessionId() * * @return string */ - public function getSessionIdSlug($prefix = 'sess-cli-') + public function getSessionIdSlug(string $prefix = 'sess-cli-'): string { return $prefix . preg_replace('/[^\w\-]+/', '-', $this->getSessionId()); } /** * Sets a new session ID. - * - * @param string $id - * @param bool $persist */ - public function setSessionId($id, $persist = false) + public function setSessionId(string $id, bool $persist = false): void { $this->config['api']['session_id'] = $id; if ($persist) { @@ -269,7 +316,7 @@ public function setSessionId($id, $persist = false) * * @return bool */ - public function isSessionIdFromEnv() + public function isSessionIdFromEnv(): bool { $sessionId = $this->getSessionId(); return $sessionId !== 'default' && $sessionId === $this->getEnv('SESSION_ID'); @@ -280,7 +327,7 @@ public function isSessionIdFromEnv() * * @return string */ - private function getSessionIdFile() + private function getSessionIdFile(): string { return $this->getWritableUserDir() . DIRECTORY_SEPARATOR . 'session-id'; } @@ -290,9 +337,9 @@ private function getSessionIdFile() * * @param string $id */ - public function validateSessionId($id) + public function validateSessionId(string $id): void { - if (strpos($id, 'api-token-') === 0 || !\preg_match('@^[a-z0-9_-]+$@i', $id)) { + if (str_starts_with($id, 'api-token-') || !\preg_match('@^[a-z0-9_-]+$@i', $id)) { throw new \InvalidArgumentException('Invalid session ID: ' . $id); } } @@ -300,11 +347,11 @@ public function validateSessionId($id) /** * Returns a new Config instance with overridden values. * - * @param array $overrides + * @param array $overrides * * @return self */ - public function withOverrides(array $overrides) + public function withOverrides(array $overrides): self { $config = new self($this->env, $this->configFile); foreach ($overrides as $key => $value) { @@ -319,9 +366,9 @@ public function withOverrides(array $overrides) * * @param string $filename * - * @return array + * @return array */ - private function loadConfigFromFile($filename) + private function loadConfigFromFile(string $filename): array { $contents = file_get_contents($filename); if ($contents === false) { @@ -337,7 +384,7 @@ private function loadConfigFromFile($filename) return (array) Yaml::parse($contents); } - private function applyEnvironmentOverrides() + private function applyEnvironmentOverrides(): void { $overrideMap = []; $types = []; @@ -418,10 +465,10 @@ private function applyEnvironmentOverrides() * @param bool $addPrefix * Whether to add the configured prefix to the variable name. * - * @return mixed|false + * @return string|false * The value of the environment variable, or false if it is not set. */ - private function getEnv($name, $addPrefix = true) + private function getEnv(string $name, bool $addPrefix = true): string|false { $prefix = $addPrefix && isset($this->config['application']['env_prefix']) ? $this->config['application']['env_prefix'] : ''; if (array_key_exists($prefix . $name, $this->env)) { @@ -431,7 +478,7 @@ private function getEnv($name, $addPrefix = true) return getenv($prefix . $name); } - private function applyUserConfigOverrides() + private function applyUserConfigOverrides(): void { $userConfigFile = $this->getUserConfigDir() . '/config.yaml'; if (!file_exists($userConfigFile)) { @@ -444,38 +491,26 @@ private function applyUserConfigOverrides() } /** - * Test if an experiment (a feature flag) is enabled. - * - * @param string $name - * - * @return bool + * Tests if an experiment (a feature flag) is enabled. */ - public function isExperimentEnabled($name) + public function isExperimentEnabled(string $name): bool { return !empty($this->config['experimental']['all_experiments']) || !empty($this->config['experimental'][$name]); } /** - * Test if a command should be hidden. - * - * @param string $name - * - * @return bool + * Tests if a command should be hidden. */ - public function isCommandHidden($name) + public function isCommandHidden(string $name): bool { return (!empty($this->config['application']['hidden_commands']) && in_array($name, $this->config['application']['hidden_commands'])); } /** - * Test if a command should be enabled. - * - * @param string $name - * - * @return bool + * Tests if a command should be enabled. */ - public function isCommandEnabled($name) + public function isCommandEnabled(string $name): bool { if (!empty($this->config['application']['disabled_commands']) && in_array($name, $this->config['application']['disabled_commands'])) { @@ -500,18 +535,17 @@ public function isCommandEnabled($name) /** * Returns this application version. - * - * @return string */ - public function getVersion() { + public function getVersion(): string + { if (isset($this->version)) { return $this->version; } $version = $this->getWithDefault('application.version', '@version-placeholder@'); - if (substr($version, 0, 1) === '@' && substr($version, -1) === '@') { + if (str_starts_with((string) $version, '@') && str_ends_with((string) $version, '@')) { // Silently try getting the version from Git. $tag = (new Shell())->execute(['git', 'describe', '--tags'], CLI_ROOT); - if ($tag !== false && substr($tag, 0, 1) === 'v') { + if (is_string($tag) && str_starts_with($tag, 'v')) { $version = trim($tag); } } @@ -525,16 +559,17 @@ public function getVersion() { * * @return string */ - public function getUserAgent() + public function getUserAgent(): string { $template = $this->getWithDefault( 'api.user_agent', - '{APP_NAME_DASH}/{VERSION} ({UNAME_S}; {UNAME_R}; PHP {PHP_VERSION})' + '{APP_NAME_DASH}/{VERSION} ({UNAME_S}; {UNAME_R}; PHP {PHP_VERSION})', ); + /** @var array $replacements */ $replacements = [ - '{APP_NAME_DASH}' => \str_replace(' ', '-', $this->get('application.name')), - '{APP_NAME}' => $this->get('application.name'), - '{APP_SLUG}' => $this->get('application.slug'), + '{APP_NAME_DASH}' => \str_replace(' ', '-', $this->getStr('application.name')), + '{APP_NAME}' => $this->getStr('application.name'), + '{APP_SLUG}' => $this->getStr('application.slug'), '{VERSION}' => $this->getVersion(), '{UNAME_S}' => \php_uname('s'), '{UNAME_R}' => \php_uname('r'), @@ -546,10 +581,10 @@ public function getUserAgent() /** * Finds proxy addresses based on the http_proxy and https_proxy environment variables. * - * @return array - * An ordered array of proxy URLs keyed by scheme: 'https' and/or 'http'. + * @return array{https?: string, http?: string} */ - public function getProxies() { + public function getProxies(): array + { $proxies = []; if (\getenv('https_proxy') !== false) { $proxies['https'] = \getenv('https_proxy'); @@ -564,17 +599,15 @@ public function getProxies() { /** * Returns an array of context options for HTTP/HTTPS streams. * - * @param int|float|null $timeout - * - * @return array + * @return array{http: array, ssl?: array} */ - public function getStreamContextOptions($timeout = null) + public function getStreamContextOptions(int|float|null $timeout = null): array { $opts = [ // See https://www.php.net/manual/en/context.http.php 'http' => [ 'method' => 'GET', - 'timeout' => $timeout !== null ? $timeout : $this->getWithDefault('api.default_timeout', 30), + 'timeout' => $timeout !== null ? $timeout : $this->getInt('api.default_timeout'), 'user_agent' => $this->getUserAgent(), ], ]; @@ -587,11 +620,11 @@ public function getStreamContextOptions($timeout = null) } // Set up SSL options. - if ($this->getWithDefault('api.skip_ssl', false)) { + if ($this->getBool('api.skip_ssl')) { $opts['ssl']['verify_peer'] = false; $opts['ssl']['verify_peer_name'] = false; } else { - $caBundlePath = \Composer\CaBundle\CaBundle::getSystemCaRootBundlePath(); + $caBundlePath = CaBundle::getSystemCaRootBundlePath(); if (\is_dir($caBundlePath)) { $opts['ssl']['capath'] = $caBundlePath; } else { @@ -607,17 +640,17 @@ public function getStreamContextOptions($timeout = null) * * @return bool */ - public function isWrapped() + public function isWrapped(): bool { - return getenv($this->get('application.env_prefix') . 'WRAPPED') === '1'; + return getenv($this->getStr('application.env_prefix') . 'WRAPPED') === '1'; } /** * Returns all the current configuration. * - * @return array + * @return array */ - public function getAll() + public function getAll(): array { return $this->config; } @@ -625,28 +658,28 @@ public function getAll() /** * Applies defaults values based on other config values. */ - private function applyDynamicDefaults() + private function applyDynamicDefaults(): void { $this->applyUrlDefaults(); $this->applyLocalDirectoryDefaults(); if (!isset($this->config['application']['slug'])) { - $this->config['application']['slug'] = preg_replace('/[^a-z0-9-]+/', '-', str_replace(['.', ' '], ['', '-'], strtolower($this->get('application.name')))); + $this->config['application']['slug'] = preg_replace('/[^a-z0-9-]+/', '-', str_replace(['.', ' '], ['', '-'], strtolower($this->getStr('application.name')))); } if (!isset($this->config['application']['tmp_sub_dir'])) { - $this->config['application']['tmp_sub_dir'] = $this->get('application.slug') . '-tmp'; + $this->config['application']['tmp_sub_dir'] = $this->getStr('application.slug') . '-tmp'; } if (!isset($this->config['api']['oauth2_client_id'])) { - $this->config['api']['oauth2_client_id'] = $this->get('application.slug'); + $this->config['api']['oauth2_client_id'] = $this->getStr('application.slug'); } if (!isset($this->config['detection']['console_domain']) && isset($this->config['service']['console_url'])) { - $consoleDomain = parse_url($this->config['service']['console_url'], PHP_URL_HOST); + $consoleDomain = parse_url((string) $this->config['service']['console_url'], PHP_URL_HOST); if ($consoleDomain !== false) { $this->config['detection']['console_domain'] = $consoleDomain; } } if (!isset($this->config['service']['applications_config_file'])) { - $this->config['service']['applications_config_file'] = $this->get('service.project_config_dir') . '/applications.yaml'; + $this->config['service']['applications_config_file'] = $this->getStr('service.project_config_dir') . '/applications.yaml'; } // Migrate renamed config keys. @@ -664,7 +697,7 @@ private function applyDynamicDefaults() } } - private function applyUrlDefaults() + private function applyUrlDefaults(): void { $authUrl = $this->getWithDefault('api.auth_url', ''); if ($authUrl === '') { @@ -678,17 +711,17 @@ private function applyUrlDefaults() ]; foreach ($defaultsUnderAuthUrl as $apiSubKey => $path) { if (!isset($this->config['api'][$apiSubKey])) { - $this->config['api'][$apiSubKey] = rtrim($authUrl, '/') . $path; + $this->config['api'][$apiSubKey] = rtrim((string) $authUrl, '/') . $path; } } } - private function applyLocalDirectoryDefaults() + private function applyLocalDirectoryDefaults(): void { if (isset($this->config['local']['local_dir'])) { $localDir = $this->config['local']['local_dir']; } else { - $localDir = $this->get('service.project_config_dir') . DIRECTORY_SEPARATOR . 'local'; + $localDir = $this->getStr('service.project_config_dir') . DIRECTORY_SEPARATOR . 'local'; $this->config['local']['local_dir'] = $localDir; } $defaultsUnderLocalDir = [ @@ -710,8 +743,8 @@ private function applyLocalDirectoryDefaults() * * @return string */ - public function getApiUrl() + public function getApiUrl(): string { - return (string) $this->get('api.base_url'); + return $this->getStr('api.base_url'); } } diff --git a/src/Service/CountryService.php b/src/Service/CountryService.php new file mode 100644 index 0000000000..d401fa9e63 --- /dev/null +++ b/src/Service/CountryService.php @@ -0,0 +1,56 @@ + */ + private array $cache; + + /** + * Returns a list of countries, keyed by 2-letter country code. + * + * @return array + */ + public function listCountries(): array + { + if (isset($this->cache)) { + return $this->cache; + } + $filename = CLI_ROOT . '/resources/cldr/countries.json'; + $data = \json_decode((string) \file_get_contents($filename), true); + if (!$data) { + throw new \RuntimeException('Failed to read CLDR file: ' . $filename); + } + return $this->cache = $data; + } + + /** + * Normalizes a given country, transforming it into a country code, if possible. + * + * @param string $country + * + * @return string + */ + public function countryToCode(string $country): string + { + $countryList = $this->listCountries(); + if (isset($countryList[$country])) { + return $country; + } + // Exact match. + if (($code = \array_search($country, $countryList)) !== false) { + return $code; + } + // Case-insensitive match. + $lower = \strtolower($country); + foreach ($countryList as $code => $name) { + if ($lower === \strtolower($name) || $lower === \strtolower($code)) { + return $code; + } + } + return $country; + } +} diff --git a/src/Service/CurlCli.php b/src/Service/CurlCli.php index 7eb67df61e..343dbe88d3 100644 --- a/src/Service/CurlCli.php +++ b/src/Service/CurlCli.php @@ -1,5 +1,7 @@ api = $api; - } - - public static function configureInput(InputDefinition $definition) + public static function configureInput(InputDefinition $definition): void { $definition->addArgument(new InputArgument('path', InputArgument::OPTIONAL, 'The API path')); $definition->addOption(new InputOption('request', 'X', InputOption::VALUE_REQUIRED, 'The request method to use')); @@ -34,31 +32,26 @@ public static function configureInput(InputDefinition $definition) /** * Runs the curl command. - * - * @param string $baseUrl - * @param InputInterface $input - * @param OutputInterface $output - * - * @return int */ - public function run($baseUrl, InputInterface $input, OutputInterface $output) { + public function run(string $baseUrl, InputInterface $input, OutputInterface $output): int + { $stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; $url = rtrim($baseUrl, '/'); if ($path = $input->getArgument('path')) { - if (parse_url($path, PHP_URL_HOST)) { + if (parse_url((string) $path, PHP_URL_HOST)) { $stdErr->writeln(sprintf('Invalid path: %s', $path)); return 1; } - $url .= '/' . ltrim($path, '/'); + $url .= '/' . ltrim((string) $path, '/'); } $token = $this->api->getAccessToken(); $commandline = sprintf( 'curl -H %s %s', escapeshellarg('Authorization: Bearer ' . $token), - escapeshellarg($url) + escapeshellarg($url), ); $passThroughFlags = ['head', 'include', 'fail']; @@ -69,21 +62,21 @@ public function run($baseUrl, InputInterface $input, OutputInterface $output) { } if ($requestMethod = $input->getOption('request')) { - $commandline .= ' --request ' . escapeshellarg($requestMethod); + $commandline .= ' --request ' . escapeshellarg((string) $requestMethod); } if ($data = $input->getOption('json')) { - if (\json_decode($data) === null && \json_last_error() !== JSON_ERROR_NONE) { + if (\json_decode((string) $data) === null && \json_last_error() !== JSON_ERROR_NONE) { $stdErr->writeln('The value of --json contains invalid JSON.'); return 1; } - $commandline .= ' --data ' . escapeshellarg($data); + $commandline .= ' --data ' . escapeshellarg((string) $data); $commandline .= ' --header ' . escapeshellarg('Content-Type: application/json'); $commandline .= ' --header ' . escapeshellarg('Accept: application/json'); } if ($data = $input->getOption('data')) { - $commandline .= ' --data ' . escapeshellarg($data); + $commandline .= ' --data ' . escapeshellarg((string) $data); } if (!$input->getOption('disable-compression')) { @@ -95,7 +88,7 @@ public function run($baseUrl, InputInterface $input, OutputInterface $output) { } foreach ($input->getOption('header') as $header) { - $commandline .= ' --header ' . escapeshellarg($header); + $commandline .= ' --header ' . escapeshellarg((string) $header); } if ($output->isVeryVerbose()) { @@ -105,14 +98,12 @@ public function run($baseUrl, InputInterface $input, OutputInterface $output) { } // Censor the access token: this can be applied to verbose output. - $censor = function ($str) use ($token) { - return str_replace($token, '[token]', $str); - }; + $censor = fn($str): array|string => str_replace($token, '[token]', $str); $stdErr->writeln(sprintf('Running command: %s', $censor($commandline)), OutputInterface::VERBOSITY_VERBOSE); - $process = new Process($commandline); - $process->run(function ($type, $buffer) use ($censor, $output, $stdErr) { + $process = Process::fromShellCommandline($commandline); + $process->run(function ($type, $buffer) use ($censor, $output, $stdErr): void { if ($type === Process::ERR) { $stdErr->write($censor($buffer)); } else { diff --git a/src/Service/Drush.php b/src/Service/Drush.php index 08e96bbb84..e02a085cd9 100644 --- a/src/Service/Drush.php +++ b/src/Service/Drush.php @@ -1,5 +1,7 @@ > */ + protected array $aliases = []; + protected string|false|null $version; + protected ?string $executable = null; /** @var string[] */ - protected $cachedAppRoots = []; - - /** @var ApplicationFinder */ - protected $applicationFinder; + protected array $cachedAppRoots = []; /** * @param Config|null $config @@ -54,11 +41,11 @@ class Drush * @param ApplicationFinder|null $applicationFinder */ public function __construct( - Config $config = null, - Shell $shellHelper = null, - LocalProject $localProject = null, - Api $api = null, - ApplicationFinder $applicationFinder = null + ?Config $config = null, + ?Shell $shellHelper = null, + ?LocalProject $localProject = null, + ?Api $api = null, + ?ApplicationFinder $applicationFinder = null, ) { $this->shellHelper = $shellHelper ?: new Shell(); $this->config = $config ?: new Config(); @@ -67,41 +54,30 @@ public function __construct( $this->applicationFinder = $applicationFinder ?: new ApplicationFinder($this->config); } - public function setHomeDir($homeDir) + public function setHomeDir(string $homeDir): void { $this->homeDir = $homeDir; } - public function getHomeDir() + public function getHomeDir(): string { return $this->homeDir ?: $this->config->getHomeDirectory(); } - /** - * @param string $sshUrl - * @param string $enterpriseAppRoot - */ - public function setCachedAppRoot($sshUrl, $enterpriseAppRoot) + public function setCachedAppRoot(string $sshUrl, string $enterpriseAppRoot): void { $this->cachedAppRoots[$sshUrl] = $enterpriseAppRoot; } - /** - * @param string $sshUrl - * - * @return string - */ - public function getCachedAppRoot($sshUrl) + public function getCachedAppRoot(string $sshUrl): string|false { - return isset($this->cachedAppRoots[$sshUrl]) ? $this->cachedAppRoots[$sshUrl] : false; + return $this->cachedAppRoots[$sshUrl] ?? false; } /** - * Find the global Drush configuration directory. - * - * @return string + * Finds the global Drush configuration directory. */ - public function getDrushDir() + public function getDrushDir(): string { return $this->getHomeDir() . '/.drush'; } @@ -111,7 +87,7 @@ public function getDrushDir() * * @return string */ - public function getSiteAliasDir() + public function getSiteAliasDir(): string { $aliasDir = $this->getDrushDir() . '/site-aliases'; if (!file_exists($aliasDir) && $this->getLegacyAliasFiles()) { @@ -126,9 +102,9 @@ public function getSiteAliasDir() * * @return string[] */ - public function getLegacyAliasFiles() + public function getLegacyAliasFiles(): array { - return glob($this->getDrushDir() . '/*.alias*.*', GLOB_NOSORT); + return glob($this->getDrushDir() . '/*.alias*.*', GLOB_NOSORT) ?: []; } /** @@ -139,11 +115,11 @@ public function getLegacyAliasFiles() * @return string|false * The Drush version, or false if it cannot be determined. */ - public function getVersion($reset = false) + public function getVersion(bool $reset = false): string|false { if ($reset || !isset($this->version)) { $this->version = $this->shellHelper->execute( - [$this->getDrushExecutable(), 'version', '--format=string'] + [$this->getDrushExecutable(), 'version', '--format=string'], ); } @@ -154,7 +130,7 @@ public function getVersion($reset = false) /** * @throws DependencyMissingException */ - public function ensureInstalled() + public function ensureInstalled(): void { if ($this->getVersion() === false) { throw new DependencyMissingException('Drush is not installed'); @@ -166,9 +142,9 @@ public function ensureInstalled() * * @return bool */ - public function supportsMakeLock() + public function supportsMakeLock(): bool { - return version_compare($this->getVersion(), '7.0.0-rc1', '>='); + return version_compare((string) $this->getVersion(), '7.0.0-rc1', '>='); } /** @@ -176,16 +152,16 @@ public function supportsMakeLock() * * @param string[] $args * Command arguments (everything after 'drush'). - * @param string $dir + * @param ?string $dir * The working directory. - * @param bool $mustRun + * @param bool $mustRun * Enable exceptions if the command fails. - * @param bool $quiet + * @param bool $quiet * Suppress command output. * - * @return string|bool + * @return string|false */ - public function execute(array $args, $dir = null, $mustRun = false, $quiet = true) + public function execute(array $args, ?string $dir = null, bool $mustRun = false, bool $quiet = true): string|false { array_unshift($args, $this->getDrushExecutable()); @@ -199,14 +175,14 @@ public function execute(array $args, $dir = null, $mustRun = false, $quiet = tru * The absolute path to the executable, or 'drush' if the path is not * known. */ - protected function getDrushExecutable() + protected function getDrushExecutable(): string { if (isset($this->executable)) { return $this->executable; } if ($this->config->has('local.drush_executable')) { - return $this->executable = $this->config->get('local.drush_executable'); + return $this->executable = $this->config->getStr('local.drush_executable'); } // Find a locally installed Drush instance: first check the Composer @@ -220,7 +196,7 @@ protected function getDrushExecutable() // Check the local dependencies directory (created via 'platform // build'). - $drushDep = $localDir . '/' . $this->config->get('local.dependencies_dir') . '/php/vendor/bin/drush'; + $drushDep = $localDir . '/' . $this->config->getStr('local.dependencies_dir') . '/php/vendor/bin/drush'; if (is_executable($drushDep)) { return $this->executable = $drushDep; } @@ -242,22 +218,19 @@ protected function getDrushExecutable() /** * @return bool */ - public function clearCache() + public function clearCache(): bool { - return (bool) $this->execute(['cache-clear', 'drush']); + return $this->execute(['cache-clear', 'drush']) !== false; } /** - * Get existing Drush aliases for a group. - * - * @param string $groupName - * @param bool $reset + * Gets existing Drush aliases for a group. * * @throws \Exception If the "drush sa" command fails. * - * @return array + * @return array> */ - public function getAliases($groupName, $reset = false) + public function getAliases(string $groupName, bool $reset = false): array { if (!$reset && isset($this->aliases[$groupName])) { return $this->aliases[$groupName]; @@ -272,10 +245,8 @@ public function getAliases($groupName, $reset = false) // Run the command with a timeout. An exception will be thrown if it fails. // A user experienced timeouts when this was set to 5 seconds, so it was increased to 30. try { - $result = $this->shellHelper->execute($args, null, true, true, [], 30); - if (is_string($result)) { - $aliases = (array) json_decode($result, true); - } + $result = $this->shellHelper->mustExecute($args, timeout: 30); + $aliases = (array) json_decode($result, true); } catch (ProcessFailedException $e) { // The command will fail if the alias is not found. Throw an // exception for any other failures. @@ -290,14 +261,9 @@ public function getAliases($groupName, $reset = false) } /** - * Get the alias group for a project. - * - * @param Project $project - * @param string $projectRoot - * - * @return string + * Gets the alias group from a project's local config file. */ - public function getAliasGroup(Project $project, $projectRoot) + public function getAliasGroup(Project $project, string $projectRoot): string { $config = $this->localProject->getProjectConfig($projectRoot); @@ -305,10 +271,9 @@ public function getAliasGroup(Project $project, $projectRoot) } /** - * @param string $newGroup - * @param string $projectRoot + * Sets and writes the alias group to the project's local config file. */ - public function setAliasGroup($newGroup, $projectRoot) + public function setAliasGroup(string $newGroup, string $projectRoot): void { $this->localProject->writeCurrentProjectConfig(['alias-group' => $newGroup], $projectRoot, true); } @@ -319,11 +284,11 @@ public function setAliasGroup($newGroup, $projectRoot) * @param Project $project The project * @param string $projectRoot The project root * @param Environment[] $environments The environments - * @param string $original The original group name + * @param ?string $original The original group name * * @return bool True on success, false on failure. */ - public function createAliases(Project $project, $projectRoot, $environments, $original = null) + public function createAliases(Project $project, string $projectRoot, array $environments, ?string $original = null): bool { if (!$apps = $this->getDrupalApps($projectRoot)) { return false; @@ -340,26 +305,22 @@ public function createAliases(Project $project, $projectRoot, $environments, $or } /** - * Find Drupal applications in a project. - * - * @param string $projectRoot + * Finds Drupal applications in a project. * * @return LocalApplication[] */ - public function getDrupalApps($projectRoot) + public function getDrupalApps(string $projectRoot): array { return array_filter( $this->applicationFinder->findApplications($projectRoot), - function (LocalApplication $app) { - return Drupal::isDrupal($app->getRoot()); - } + fn(LocalApplication $app): bool => Drupal::isDrupal($app->getRoot()), ); } /** * @return SiteAliasTypeInterface[] */ - protected function getSiteAliasTypes() + protected function getSiteAliasTypes(): array { $types = []; $types[] = new DrushYaml($this->config, $this); @@ -370,15 +331,8 @@ protected function getSiteAliasTypes() /** * Returns the site URL. - * - * @param Environment $environment - * @param LocalApplication $app - * - * @todo this is really a hidden dependency on the Api service - * - * @return string|null */ - public function getSiteUrl(Environment $environment, LocalApplication $app) + public function getSiteUrl(Environment $environment, LocalApplication $app): ?string { if ($this->api->hasCachedCurrentDeployment($environment)) { return $this->api->getSiteUrl($environment, $app->getName()); @@ -392,10 +346,7 @@ public function getSiteUrl(Environment $environment, LocalApplication $app) return null; } - /** - * @param string $group - */ - public function deleteOldAliases($group) + public function deleteOldAliases(string $group): void { foreach ($this->getSiteAliasTypes() as $type) { $type->deleteAliases($group); diff --git a/src/Service/DrushAliasUpdater.php b/src/Service/DrushAliasUpdater.php new file mode 100644 index 0000000000..7818296594 --- /dev/null +++ b/src/Service/DrushAliasUpdater.php @@ -0,0 +1,63 @@ +stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; + } + + /** + * Reacts on the environments list changing, to update Drush aliases. + */ + public function onEnvironmentsChanged(EnvironmentsChangedEvent $event): void + { + // Make sure the local:drush-aliases command is enabled. + if (!$this->config->isCommandEnabled('local:drush-aliases')) { + return; + } + // Check we are in a local project. + $projectRoot = $this->localProject->getProjectRoot(); + if (!$projectRoot) { + return; + } + // Double-check that the passed project is the current one. + $projectConfig = $this->localProject->getProjectConfig($projectRoot); + if (!$projectConfig || empty($projectConfig['id']) || $projectConfig['id'] !== $event->getProject()->id) { + return; + } + // Ignore the project if it doesn't contain a Drupal application. + if (!Drupal::isDrupal($projectRoot)) { + return; + } + if ($this->drush->getVersion() === false) { + $this->stdErr->writeln('DEBUG Not updating Drush aliases: the Drush version cannot be determined.', OutputInterface::VERBOSITY_DEBUG); + return; + } + $this->stdErr->writeln('DEBUG Updating Drush aliases', OutputInterface::VERBOSITY_DEBUG); + try { + $this->drush->createAliases($event->getProject(), $projectRoot, $event->getEnvironments()); + } catch (\Exception $e) { + $this->stdErr->writeln(sprintf( + "Failed to update Drush aliases:\n%s\n", + preg_replace('/^/m', ' ', trim($e->getMessage())), + )); + } + } +} diff --git a/src/Service/FileLock.php b/src/Service/FileLock.php index c9d1977499..cb57d59a67 100644 --- a/src/Service/FileLock.php +++ b/src/Service/FileLock.php @@ -1,23 +1,23 @@ */ + private array $locks = []; - public function __construct(Config $config) + public function __construct(private readonly Config $config) { - $this->config = $config; $this->checkIntervalMs = 500; $this->timeLimit = 30; - $this->disabled = (bool) $this->config->getWithDefault('api.disable_locks', false); + $this->disabled = $this->config->getBool('api.disable_locks'); } /** @@ -34,7 +34,7 @@ public function __construct(Config $config) * * @return mixed|null */ - public function acquireOrWait($lockName, callable $onWait = null, callable $check = null) + public function acquireOrWait(string $lockName, ?callable $onWait = null, ?callable $check = null): mixed { if ($this->disabled) { return null; @@ -71,10 +71,8 @@ public function acquireOrWait($lockName, callable $onWait = null, callable $chec /** * Releases a lock that was created by acquire(). - * - * @param string $lockName */ - public function release($lockName) + public function release(string $lockName): void { if (!$this->disabled && isset($this->locks[$lockName])) { $this->writeWithLock($this->filename($lockName), ''); @@ -94,11 +92,8 @@ public function __destruct() /** * Finds the filename for a lock. - * - * @param string $lockName - * @return string */ - private function filename($lockName) + private function filename(string $lockName): string { return $this->config->getWritableUserDir() . DIRECTORY_SEPARATOR . 'locks' @@ -113,7 +108,7 @@ private function filename($lockName) * @param string $filename * @return string */ - private function readWithLock($filename) + private function readWithLock(string $filename): string { $handle = \fopen($filename, 'r'); if (!$handle) { @@ -135,7 +130,7 @@ private function readWithLock($filename) \trigger_error('Failed to close file: ' . $filename, E_USER_WARNING); } } - return $content; + return (string) $content; } /** @@ -145,11 +140,11 @@ private function readWithLock($filename) * @param string $content * @return void */ - private function writeWithLock($filename, $content) + private function writeWithLock(string $filename, string $content): void { $dir = \dirname($filename); if (!\is_dir($dir)) { - if (!\mkdir($dir, 0777, true)) { + if (!\mkdir($dir, 0o777, true)) { throw new \RuntimeException('Failed to create directory: ' . $dir); } } @@ -164,12 +159,8 @@ private function writeWithLock($filename, $content) if (\fputs($handle, $content) === false) { throw new \RuntimeException('Failed to write to file: ' . $filename); } - if (PHP_VERSION_ID >= 81000) { - if (!\fsync($handle)) { - \trigger_error('Failed to sync file (fsync): ' . $filename, E_USER_WARNING); - } - } elseif (!\fflush($handle)) { - \trigger_error('Failed to flush file (fflush): ' . $filename, E_USER_WARNING); + if (!\fsync($handle)) { + \trigger_error('Failed to sync file (fsync): ' . $filename, E_USER_WARNING); } } finally { if (!\flock($handle, LOCK_UN)) { diff --git a/src/Service/Filesystem.php b/src/Service/Filesystem.php index b0dd650309..c6cd3025a5 100644 --- a/src/Service/Filesystem.php +++ b/src/Service/Filesystem.php @@ -1,5 +1,7 @@ shell = $shell ?: new Shell(); $this->fs = $fs ?: new SymfonyFilesystem(); } - /** - * @param bool $copyOnWindows - */ - public function setCopyOnWindows($copyOnWindows = true) + public function setCopyOnWindows(bool $copyOnWindows = true): void { $this->copyOnWindows = $copyOnWindows; } /** - * Set whether to use relative links. - * - * @param bool $relative + * Sets whether to use relative links. */ - public function setRelativeLinks($relative = true) + public function setRelativeLinks(bool $relative = true): void { // This is not possible on Windows. if (OsUtil::isWindows()) { @@ -51,15 +46,15 @@ public function setRelativeLinks($relative = true) /** * Delete a file or directory. * - * @param string|array|\Traversable $files - * A filename, an array of files, or a \Traversable instance to delete. - * @param bool $retryWithChmod + * @param string|iterable $files + * A filename or an iterable list of files to delete. + * @param bool $retryWithChmod * Whether to retry deleting on error, after recursively changing file * modes to add read/write/exec permissions. A bit like 'rm -rf'. * * @return bool */ - public function remove($files, $retryWithChmod = false) + public function remove(string|iterable $files, bool $retryWithChmod = false): bool { try { $this->fs->remove($files); @@ -78,18 +73,18 @@ public function remove($files, $retryWithChmod = false) /** * Make files writable by the current user. * - * @param string|array|\Traversable $files - * A filename, an array of files, or a \Traversable instance. + * @param string|iterable $files + * A filename or an iterable list of files. * @param bool $recursive * Whether to change the mode recursively or not. * * @return bool * True on success, false on failure. */ - protected function unprotect($files, $recursive = false) + protected function unprotect(string|iterable $files, bool $recursive = false): bool { if (!$files instanceof \Traversable) { - $files = new \ArrayObject(is_array($files) ? $files : array($files)); + $files = new \ArrayObject(is_array($files) ? $files : [$files]); } foreach ($files as $file) { @@ -97,13 +92,13 @@ protected function unprotect($files, $recursive = false) continue; } elseif (is_dir($file)) { if ((!is_executable($file) || !is_writable($file)) - && true !== @chmod($file, 0700)) { + && true !== @chmod($file, 0o700)) { return false; } if ($recursive && !$this->unprotect(new \FilesystemIterator($file), true)) { return false; } - } elseif (!is_writable($file) && true !== @chmod($file, 0600)) { + } elseif (!is_writable($file) && true !== @chmod($file, 0o600)) { return false; } } @@ -111,23 +106,15 @@ protected function unprotect($files, $recursive = false) return true; } - /** - * @param string $dir - * @param int $mode - */ - public function mkdir($dir, $mode = 0755) + public function mkdir(string $dir, int $mode = 0o755): void { $this->fs->mkdir($dir, $mode); } /** - * Copy a file, if it is newer than the destination. - * - * @param string $source - * @param string $destination - * @param bool $override + * Copies a file, if it is newer than the destination. */ - public function copy($source, $destination, $override = false) + public function copy(string $source, string $destination, bool $override = false): void { if (is_dir($destination) && !is_dir($source)) { $destination = rtrim($destination, '/') . '/' . basename($source); @@ -136,17 +123,14 @@ public function copy($source, $destination, $override = false) } /** - * Copy all files and folders between directories. + * Copies all files and folders between directories. * - * @param string $source - * @param string $destination - * @param array $skip - * @param bool $override + * @param string[] $skip Paths to skip. */ - public function copyAll($source, $destination, array $skip = ['.git', '.DS_Store'], $override = false) + public function copyAll(string $source, string $destination, array $skip = ['.git', '.DS_Store'], bool $override = false): void { if (is_dir($source) && !is_dir($destination)) { - if (!mkdir($destination, 0755, true)) { + if (!mkdir($destination, 0o755, true)) { throw new \RuntimeException("Failed to create directory: " . $destination); } } @@ -154,13 +138,16 @@ public function copyAll($source, $destination, array $skip = ['.git', '.DS_Store if (is_dir($source)) { // Prevent infinite recursion when the destination is inside the // source. - if (strpos($destination, $source) === 0) { + if (str_starts_with($destination, $source)) { $relative = str_replace($source, '', $destination); $parts = explode('/', ltrim($relative, '/'), 2); $skip[] = $parts[0]; } $sourceDirectory = opendir($source); + if (!$sourceDirectory) { + throw new \RuntimeException("Failed to open directory: " . $source); + } while ($file = readdir($sourceDirectory)) { // Skip symlinks, '.' and '..', and files in $skip. if ($file === '.' @@ -193,7 +180,7 @@ public function copyAll($source, $destination, array $skip = ['.git', '.DS_Store * @param string $target The target to link to (must already exist). * @param string $link The name of the symbolic link. */ - public function symlink($target, $link) + public function symlink(string $target, string $link): void { if (!file_exists($target)) { throw new \InvalidArgumentException('Target not found: ' . $target); @@ -213,12 +200,12 @@ public function symlink($target, $link) * @param string $path An absolute path. * @param string $reference The path to which it will be made relative. * - * @see SymfonyFilesystem::makePathRelative() - * * @return string * The $path, relative to the $reference. + * + * @see SymfonyFilesystem::makePathRelative() */ - public function makePathRelative($path, $reference) + public function makePathRelative(string $path, string $reference): string { $path = realpath($path) ?: $path; $reference = realpath($reference) ?: $reference; @@ -227,16 +214,12 @@ public function makePathRelative($path, $reference) } /** - * Format a path for display (use the relative path if it's simpler). - * - * @param string $path - * - * @return string + * Formats a path for display (uses the relative path if it's simpler). */ - public function formatPathForDisplay($path) + public function formatPathForDisplay(string $path): string { - $relative = $this->makePathRelative($path, getcwd()); - if (strpos($relative, '../..') === false && strlen($relative) < strlen($path)) { + $relative = $this->makePathRelative($path, (string) getcwd()); + if (!str_contains($relative, '../..') && strlen($relative) < strlen($path)) { return $relative; } @@ -246,12 +229,12 @@ public function formatPathForDisplay($path) /** * Check if a filename is in a list. * - * @param string $filename + * @param string $filename * @param string[] $list * * @return bool */ - protected function fileInList($filename, array $list) + protected function fileInList(string $filename, array $list): bool { foreach ($list as $pattern) { if (fnmatch($pattern, $filename, FNM_PATHNAME | FNM_CASEFOLD)) { @@ -265,41 +248,47 @@ protected function fileInList($filename, array $list) /** * Symlink or copy all files and folders between two directories. * - * @param string $source + * @param string $source * @param string $destination - * @param bool $skipExisting - * @param bool $recursive + * @param bool $skipExisting + * @param bool $recursive * @param string[] $exclude - * @param bool $copy + * @param bool $copy * * @throws \Exception When a conflict is discovered. */ public function symlinkAll( - $source, - $destination, - $skipExisting = true, - $recursive = false, - $exclude = [], - $copy = false - ) { + string $source, + string $destination, + bool $skipExisting = true, + bool $recursive = false, + array $exclude = [], + bool $copy = false, + ): void { if (!is_dir($destination)) { mkdir($destination); } // The symlink won't work if $source is a relative path. - $source = realpath($source); + $absPath = realpath($source); + if (!$absPath) { + throw new \RuntimeException('Failed to resolve path: ' . $source); + } // Files to always skip. $skip = ['.git', '.DS_Store']; $skip = array_merge($skip, $exclude); - $sourceDirectory = opendir($source); + $sourceDirectory = opendir($absPath); + if (!$sourceDirectory) { + throw new \RuntimeException('Failed to open directory: ' . $absPath); + } while ($file = readdir($sourceDirectory)) { // Skip symlinks, '.' and '..', and files in $skip. - if ($file === '.' || $file === '..' || $this->fileInList($file, $skip) || is_link($source . '/' . $file)) { + if ($file === '.' || $file === '..' || $this->fileInList($file, $skip) || is_link($absPath . '/' . $file)) { continue; } - $sourceFile = $source . '/' . $file; + $sourceFile = $absPath . '/' . $file; $linkFile = $destination . '/' . $file; if ($recursive && !is_link($linkFile) && is_dir($linkFile) && is_dir($sourceFile)) { @@ -326,18 +315,14 @@ public function symlinkAll( } /** - * Make a relative path into an absolute one. + * Makes a relative path into an absolute one. * * The realpath() function will only work for existing files, and not for * symlinks. This is a more flexible solution. * - * @param string $relativePath - * * @throws \InvalidArgumentException If the parent directory is not found. - * - * @return string */ - public function makePathAbsolute($relativePath) + public function makePathAbsolute(string $relativePath): string { if (file_exists($relativePath) && !is_link($relativePath) && ($realPath = realpath($relativePath))) { $absolute = $realPath; @@ -356,13 +341,9 @@ public function makePathAbsolute($relativePath) } /** - * Test whether a file or directory is writable even if it does not exist. - * - * @param string $name - * - * @return bool + * Tests whether a file or directory is writable even if it does not exist. */ - public function canWrite($name) + public function canWrite(string $name): bool { if (is_writable($name)) { return true; @@ -380,13 +361,9 @@ public function canWrite($name) } /** - * Write a file and create a backup if the contents have changed. - * - * @param string $filename - * @param string $contents - * @param bool $backup + * Writes a file and creates a backup if the contents have changed. */ - public function writeFile($filename, $contents, $backup = true) + public function writeFile(string $filename, string $contents, bool $backup = true): void { $fs = new SymfonyFilesystem(); if (file_exists($filename) && $backup && $contents !== file_get_contents($filename)) { @@ -397,12 +374,9 @@ public function writeFile($filename, $contents, $backup = true) } /** - * Create a gzipped tar archive of a directory's contents. - * - * @param string $dir - * @param string $destination + * Creates a gzipped tar archive of a directory's contents. */ - public function archiveDir($dir, $destination) + public function archiveDir(string $dir, string $destination): void { $tar = $this->getTarExecutable(); $dir = $this->fixTarPath($dir); @@ -411,17 +385,14 @@ public function archiveDir($dir, $destination) } /** - * Extract a gzipped tar archive into the specified destination directory. - * - * @param string $archive - * @param string $destination + * Extracts a gzipped tar archive into the specified destination directory. */ - public function extractArchive($archive, $destination) + public function extractArchive(string $archive, string $destination): void { if (!file_exists($archive)) { throw new \InvalidArgumentException("Archive not found: $archive"); } - if (!file_exists($destination) && !mkdir($destination, 0755, true)) { + if (!file_exists($destination) && !mkdir($destination, 0o755, true)) { throw new \InvalidArgumentException("Could not create destination directory: $destination"); } $tar = $this->getTarExecutable(); @@ -431,23 +402,17 @@ public function extractArchive($archive, $destination) } /** - * Fix a path so that it can be used with tar on Windows. + * Fixes a path so that it can be used with tar on Windows. * * @see http://betterlogic.com/roger/2009/01/tar-woes-with-windows/ - * - * @param string $path - * - * @return string */ - protected function fixTarPath($path) + protected function fixTarPath(string $path): string { if (OsUtil::isWindows()) { $path = preg_replace_callback( '#^([A-Z]):/#i', - function (array $matches) { - return '/' . strtolower($matches[1]) . '/'; - }, - str_replace('\\', '/', $path) + fn(array $matches): string => '/' . strtolower((string) $matches[1]) . '/', + str_replace('\\', '/', $path), ); } @@ -457,7 +422,7 @@ function (array $matches) { /** * @return string */ - protected function getTarExecutable() + protected function getTarExecutable(): string { $candidates = ['tar', 'tar.exe', 'bsdtar.exe']; foreach ($candidates as $command) { @@ -470,11 +435,8 @@ protected function getTarExecutable() /** * Validates a directory. - * - * @param string $directory - * @param bool $writable */ - public function validateDirectory($directory, $writable = false) + public function validateDirectory(string $directory, bool $writable = false): void { if (!is_dir($directory)) { throw new \InvalidArgumentException(sprintf('Directory not found: %s', $directory)); diff --git a/src/Service/Git.php b/src/Service/Git.php index 56c91b1706..092a9f5089 100644 --- a/src/Service/Git.php +++ b/src/Service/Git.php @@ -1,38 +1,32 @@ shell = $shell ?: new Shell(); - $this->ssh = $ssh; } /** @@ -41,13 +35,13 @@ public function __construct(Shell $shell = null, Ssh $ssh = null) * @return string|false * The version number, or false on failure. */ - private function getVersion() + private function getVersion(): false|string { static $version; if (!$version) { $version = false; $string = $this->execute(['--version'], false); - if ($string && preg_match('/(^| )([0-9]+[^ ]*)/', $string, $matches)) { + if (is_string($string) && preg_match('/(^| )([0-9]+[^ ]*)/', $string, $matches)) { $version = $matches[2]; } } @@ -60,7 +54,7 @@ private function getVersion() * * @throws DependencyMissingException */ - public function ensureInstalled() + public function ensureInstalled(): void { try { $this->execute(['--version'], null, true); @@ -79,22 +73,22 @@ public function ensureInstalled() * @param string $dir * The path to a Git repository. */ - public function setDefaultRepositoryDir($dir) + public function setDefaultRepositoryDir(string $dir): void { $this->repositoryDir = $dir; } /** - * Get the current branch name. + * Gets the current branch name. * - * @param string $dir + * @param string|null $dir * The path to a Git repository. * @param bool $mustRun * Enable exceptions if the Git command fails. * * @return string|false */ - public function getCurrentBranch($dir = null, $mustRun = false) + public function getCurrentBranch(?string $dir = null, bool $mustRun = false): string|false { $args = ['symbolic-ref', '--short', 'HEAD']; @@ -104,32 +98,29 @@ public function getCurrentBranch($dir = null, $mustRun = false) /** * Execute a Git command. * - * @param string[] $args + * @param string[] $args * Command arguments (everything after 'git'). * @param string|false|null $dir * The path to a Git repository. Set to false if the command should not * run inside a repository. Set to null to use the default repository. - * @param bool $mustRun + * @param bool $mustRun * Enable exceptions if the Git command fails. - * @param bool $quiet + * @param bool $quiet * Suppress command output. - * @param array $env - * @param bool $online - * @param string $uri + * @param array $env + * @param bool $online + * @param string $uri * - * @throws \Symfony\Component\Process\Exception\RuntimeException + * @return string|false + * The command output or false if the command fails. + *@throws RuntimeException * If the command fails and $mustRun is enabled. * - * @return string|bool - * The command output, true if there is no output, or false if the command - * fails. */ - public function execute(array $args, $dir = null, $mustRun = false, $quiet = true, array $env = [], $online = false, $uri = '') + public function execute(array $args, string|false|null $dir = null, bool $mustRun = false, bool $quiet = true, array $env = [], bool $online = false, string $uri = ''): string|false { // If enabled, set the working directory to the repository. - if ($dir !== false) { - $dir = $dir ?: $this->repositoryDir; - } + $dir = $dir !== false ? ($dir ?: $this->repositoryDir) : null; // Set up SSH, if the Git command might connect to a remote. if ($online) { $env += $this->setupSshEnv($uri); @@ -141,15 +132,26 @@ public function execute(array $args, $dir = null, $mustRun = false, $quiet = tru } /** - * Create a Git repository in a directory. + * Executes a Git command and returns its output, throwing an exception on failure. * - * @param string $dir - * @param bool $mustRun - * Enable exceptions if the Git command fails. + * @param string[] $args Command arguments (everything after 'git'). + * @param array $env * - * @return bool + * @return string The command output. + * @throws RuntimeException If the command fails. + */ + public function mustExecute(array $args, string|false|null $dir = null, bool $quiet = true, array $env = [], bool $online = false, string $uri = ''): string + { + return (string) $this->execute($args, $dir, true, $quiet, $env, $online, $uri); + } + + /** + * Creates a Git repository in a directory. + * + * @param bool $mustRun + * Enable exceptions if the Git command fails. */ - public function init($dir, $initial_branch = '', $mustRun = false) + public function init(string $dir, string $initial_branch = '', bool $mustRun = false): bool { if (is_dir($dir . '/.git')) { throw new \InvalidArgumentException("Already a repository: $dir"); @@ -159,22 +161,22 @@ public function init($dir, $initial_branch = '', $mustRun = false) if ($initial_branch !== '' && $this->supportsGitInitialBranchFlag()) { $args[] = "--initial-branch=$initial_branch"; } - return (bool) $this->execute($args, $dir, $mustRun, false); + return $this->execute($args, $dir, $mustRun, false) !== false; } /** - * Check whether a remote repository exists. + * Checks whether a remote repository exists. * * @param string $url - * @param string $ref - * @param bool $heads Whether to limit to heads only. - * - * @throws \Symfony\Component\Process\Exception\RuntimeException - * If the Git command fails. + * @param ?string $ref + * @param bool $heads Whether to limit to heads only. * * @return bool + * + * @throws RuntimeException + * If the Git command fails. */ - public function remoteRefExists($url, $ref = null, $heads = true) + public function remoteRefExists(string $url, ?string $ref = null, bool $heads = true): bool { $args = ['ls-remote', $url]; if ($heads) { @@ -183,9 +185,9 @@ public function remoteRefExists($url, $ref = null, $heads = true) if ($ref !== null) { $args[] = $ref; } - $result = $this->execute($args, false, true, true, [], true, $url); + $result = $this->mustExecute($args, dir: false, online: true, uri: $url); - return !is_bool($result) && strlen($result) > 0; + return strlen($result) > 0; } /** @@ -193,37 +195,32 @@ public function remoteRefExists($url, $ref = null, $heads = true) * * @param string $branchName * The branch name. - * @param string $dir + * @param ?string $dir * The path to a Git repository. - * @param bool $mustRun + * @param bool $mustRun * Enable exceptions if the Git command fails. * * @return bool */ - public function branchExists($branchName, $dir = null, $mustRun = false) + public function branchExists(string $branchName, ?string $dir = null, bool $mustRun = false): bool { // The porcelain command 'git branch' is less strict about character // encoding than (otherwise simpler) plumbing commands such as // 'git show-ref'. $result = $this->execute(['branch', '--list', '--no-color', '--no-column'], $dir, $mustRun); - $branches = array_map(function ($line) { - return trim(ltrim($line, '* ')); - }, explode("\n", $result)); + if ($result === false) { + return false; + } + + $branches = array_map(fn($line) => trim(ltrim($line, '* ')), explode("\n", $result)); return in_array($branchName, $branches, true); } /** - * Check whether a branch exists on a remote. - * - * @param string $remote - * @param string $branchName - * @param null $dir - * @param bool $mustRun - * - * @return bool + * Checks whether a branch exists on a remote. */ - public function remoteBranchExists($remote, $branchName, $dir = null, $mustRun = false) + public function remoteBranchExists(string $remote, string $branchName, ?string $dir = null, bool $mustRun = false): bool { $args = ['ls-remote', $remote, $branchName]; $result = $this->execute($args, $dir, $mustRun, true, [], true); @@ -234,15 +231,15 @@ public function remoteBranchExists($remote, $branchName, $dir = null, $mustRun = /** * Create a new branch and check it out. * - * @param string $name + * @param string $name * @param string|null $parent * @param string|null $upstream - * @param string|null $dir The path to a Git repository. - * @param bool $mustRun Enable exceptions if the Git command fails. + * @param string|null $dir The path to a Git repository. + * @param bool $mustRun Enable exceptions if the Git command fails. * * @return bool */ - public function checkOutNew($name, $parent = null, $upstream = null, $dir = null, $mustRun = false) + public function checkOutNew(string $name, ?string $parent = null, ?string $upstream = null, ?string $dir = null, bool $mustRun = false): bool { $args = ['checkout', '-b', $name]; if ($parent !== null) { @@ -252,42 +249,27 @@ public function checkOutNew($name, $parent = null, $upstream = null, $dir = null $args[] = $upstream; } - return (bool) $this->execute($args, $dir, $mustRun, false); + return $this->execute($args, $dir, $mustRun, false) !== false; } /** - * Fetch from the Git remote. - * - * @param string $remote - * @param string|null $branch - * @param string $uri - * @param string|null $dir - * @param bool $mustRun - * - * @return bool + * Fetches from the Git remote. */ - public function fetch($remote, $branch = null, $uri = '', $dir = null, $mustRun = false) + public function fetch(string $remote, ?string $branch = null, string $uri = '', ?string $dir = null, bool $mustRun = false): bool { $args = ['fetch', $remote]; if ($branch !== null) { $args[] = $branch; } - return (bool) $this->execute($args, $dir, $mustRun, false, [], true, $uri); + return $this->execute($args, $dir, $mustRun, false, [], true, $uri) !== false; } /** - * Pull a ref from a repository. - * - * @param string $repository A remote repository name or URL. - * @param string $ref - * @param string|null $dir - * @param bool $mustRun - * @param bool $quiet - * - * @return bool + * Pulls a ref from a repository (runs "git pull"). */ - public function pull($repository = null, $ref = null, $dir = null, $mustRun = true, $quiet = false) { + public function pull(?string $repository = null, ?string $ref = null, ?string $dir = null, bool $mustRun = true, bool $quiet = false): bool + { $args = ['pull']; if ($repository !== null) { $args[] = $repository; @@ -296,45 +278,45 @@ public function pull($repository = null, $ref = null, $dir = null, $mustRun = tr $args[] = $ref; } - return (bool) $this->execute($args, $dir, $mustRun, $quiet, [], true, $repository); + return $this->execute($args, $dir, $mustRun, $quiet, [], true, $repository) !== false; } /** - * Check out a branch. + * Checks out a branch. * - * @param string $name + * @param string $name * @param string|null $dir * The path to a Git repository. - * @param bool $mustRun + * @param bool $mustRun * Enable exceptions if the Git command fails. - * @param bool $quiet + * @param bool $quiet * * @return bool */ - public function checkOut($name, $dir = null, $mustRun = false, $quiet = false) + public function checkOut(string $name, ?string $dir = null, bool $mustRun = false, bool $quiet = false): bool { - return (bool) $this->execute([ + return $this->execute([ 'checkout', $name, - ], $dir, $mustRun, $quiet); + ], $dir, $mustRun, $quiet) !== false; } /** * Get the upstream for a branch. * - * @param string $branch + * @param ?string $branch * The name of the branch to get the upstream for. Defaults to the current * branch. * @param string|null $dir * The path to a Git repository. - * @param bool $mustRun + * @param bool $mustRun * Enable exceptions if the Git command fails. * * @return string|false * The upstream, in the form remote/branch, or false if no upstream is * found. */ - public function getUpstream($branch = null, $dir = null, $mustRun = false) + public function getUpstream(?string $branch = null, ?string $dir = null, bool $mustRun = false): string|false { if ($branch === null) { $args = ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']; @@ -352,7 +334,7 @@ public function getUpstream($branch = null, $dir = null, $mustRun = false) } /** - * Set the upstream for the current branch. + * Sets the upstream for the current branch. * * @param string|false $upstream * The upstream name, or false to unset the upstream. @@ -360,12 +342,12 @@ public function getUpstream($branch = null, $dir = null, $mustRun = false) * The branch to act on (defaults to the current branch). * @param string|null $dir * The path to a Git repository. - * @param bool $mustRun + * @param bool $mustRun * Enable exceptions if the Git command fails. * * @return bool */ - public function setUpstream($upstream, $branch = null, $dir = null, $mustRun = false) + public function setUpstream(string|false $upstream, ?string $branch = null, ?string $dir = null, bool $mustRun = false): bool { $args = ['branch']; if ($upstream !== false) { @@ -377,23 +359,23 @@ public function setUpstream($upstream, $branch = null, $dir = null, $mustRun = f $args[] = $branch; } - return (bool) $this->execute($args, $dir, $mustRun); + return $this->execute($args, $dir, $mustRun) !== false; } /** * @return bool */ - public function supportsGitSshCommand() + public function supportsGitSshCommand(): bool { - return version_compare($this->getVersion(), '2.3', '>='); + return version_compare($this->getVersion() ?: '', '2.3', '>='); } /** * @return bool */ - public function supportsGitInitialBranchFlag() + public function supportsGitInitialBranchFlag(): bool { - return version_compare($this->getVersion(), '2.28', '>='); + return version_compare($this->getVersion() ?: '', '2.28', '>='); } /** @@ -403,23 +385,23 @@ public function supportsGitInitialBranchFlag() * * @param string $urlOrPath * The Git repository URL or file path. - * @param string $destination + * @param ?string $destination * A directory name to clone into. - * @param array $args + * @param string[] $args * Extra arguments for the Git command. - * @param bool $mustRun + * @param bool $mustRun * Enable exceptions if the Git command fails. * * @return bool */ - public function cloneRepo($urlOrPath, $destination = null, array $args = [], $mustRun = false) + public function cloneRepo(string $urlOrPath, ?string $destination = null, array $args = [], bool $mustRun = false): bool { $args = array_merge(['clone', $urlOrPath], $args); if ($destination) { $args[] = $destination; } - return (bool) $this->execute($args, false, $mustRun, false, [], $urlOrPath[0] !== '/', $urlOrPath); + return $this->execute($args, false, $mustRun, false, [], $urlOrPath[0] !== '/', $urlOrPath) !== false; } /** @@ -429,12 +411,12 @@ public function cloneRepo($urlOrPath, $destination = null, array $args = [], $mu * * @param string|null $dir * The starting directory (defaults to the current working directory). - * @param bool $mustRun + * @param bool $mustRun * Causes an exception to be thrown if the directory is not a repository. * * @return string|false */ - public function getRoot($dir = null, $mustRun = false) + public function getRoot(?string $dir = null, bool $mustRun = false): string|false { $dir = $dir ?: getcwd(); if ($dir === false) { @@ -462,53 +444,48 @@ public function getRoot($dir = null, $mustRun = false) } /** - * Check whether a file is excluded via .gitignore or similar configuration. - * - * @param string $file - * @param string|null $dir - * - * @return bool + * Checks whether a file is excluded via .gitignore or similar configuration. */ - public function checkIgnore($file, $dir = null) + public function checkIgnore(string $file, ?string $dir = null): bool { - return (bool) $this->execute(['check-ignore', $file], $dir); + return $this->execute(['check-ignore', $file], $dir) !== false; } /** * Update and/or initialize submodules. * - * @param bool $recursive + * @param bool $recursive * Whether to recurse into nested submodules. * @param string|null $dir * The path to a Git repository. - * @param bool $mustRun + * @param bool $mustRun * Enable exceptions if the Git command fails. * * @return bool */ - public function updateSubmodules($recursive = false, $dir = null, $mustRun = false) + public function updateSubmodules(bool $recursive = false, ?string $dir = null, bool $mustRun = false): bool { $args = ['submodule', 'update', '--init']; if ($recursive) { $args[] = '--recursive'; } - return (bool) $this->execute($args, $dir, $mustRun, false); + return $this->execute($args, $dir, $mustRun, false) !== false; } /** - * Read a configuration item. + * Reads a configuration item. * - * @param string $key + * @param string $key * A Git configuration key. * @param string|null $dir * The path to a Git repository. - * @param bool $mustRun + * @param bool $mustRun * Enable exceptions if the Git command fails. * * @return string|false */ - public function getConfig($key, $dir = null, $mustRun = false) + public function getConfig(string $key, ?string $dir = null, bool $mustRun = false): string|false { $args = ['config', '--get', $key]; @@ -520,7 +497,7 @@ public function getConfig($key, $dir = null, $mustRun = false) * * @param string[] $options */ - public function setExtraSshOptions(array $options) + public function setExtraSshOptions(array $options): void { $this->extraSshOptions = $options; } @@ -533,9 +510,9 @@ public function setExtraSshOptions(array $options) * * @param string $gitUri * - * @return array + * @return array */ - public function setupSshEnv($gitUri) + public function setupSshEnv(string $gitUri): array { if (!isset($this->ssh)) { return []; @@ -561,7 +538,7 @@ public function setupSshEnv($gitUri) * * @return string */ - private function writeSshFile($sshCommand) + private function writeSshFile(string $sshCommand): string { $tempDir = sys_get_temp_dir(); $tempFile = tempnam($tempDir, 'cli-git-ssh'); @@ -571,7 +548,7 @@ private function writeSshFile($sshCommand) if (!file_put_contents($tempFile, trim($sshCommand) . "\n")) { throw new \RuntimeException('Failed to write temporary file: ' . $tempFile); } - if (!chmod($tempFile, 0750)) { + if (!chmod($tempFile, 0o750)) { throw new \RuntimeException('Failed to make temporary SSH command file executable: ' . $tempFile); } diff --git a/src/Service/GitDataApi.php b/src/Service/GitDataApi.php index 3cafcda91b..b86075d47b 100644 --- a/src/Service/GitDataApi.php +++ b/src/Service/GitDataApi.php @@ -1,9 +1,12 @@ api = $api ?: new Api(); $this->cache = $cache ?: CacheFactory::createCacheProvider(new Config()); @@ -36,7 +39,7 @@ public function __construct( * @return int[] * A list of parents. */ - private function parseParents($sha) + private function parseParents(string $sha): array { if (!strpos($sha, '^') && !strpos($sha, '~')) { return []; @@ -59,14 +62,9 @@ private function parseParents($sha) } /** - * Get a Git Commit object for an environment. - * - * @param \Platformsh\Client\Model\Environment $environment - * @param string|null $sha - * - * @return \Platformsh\Client\Model\Git\Commit|false + * Gets a Git Commit object for an environment. */ - public function getCommit(Environment $environment, $sha = null) + public function getCommit(Environment $environment, ?string $sha = null): false|Commit { $sha = $this->normalizeSha($environment, $sha); @@ -98,9 +96,9 @@ public function getCommit(Environment $environment, $sha = null) * @param Environment $environment * @param string $sha The "pure" commit SHA hash. * - * @return \Platformsh\Client\Model\Git\Commit|false + * @return Commit|false */ - private function getCommitByShaHash(Environment $environment, $sha) + private function getCommitByShaHash(Environment $environment, string $sha): Commit|false { $cacheKey = $environment->project . ':' . $sha; $client = $this->api->getHttpClient(); @@ -126,19 +124,14 @@ private function getCommitByShaHash(Environment $environment, $sha) } /** - * Normalize a commit SHA for API and caching purposes. - * - * @param \Platformsh\Client\Model\Environment $environment - * @param string|null $sha - * - * @return string|null + * Normalizes a commit SHA for API and caching purposes. */ - private function normalizeSha(Environment $environment, $sha = null) + private function normalizeSha(Environment $environment, ?string $sha = null): string { if ($sha === null) { return $this->getHeadSha($environment); } - if (strpos($sha, 'HEAD') === 0) { + if (str_starts_with($sha, 'HEAD')) { $sha = $this->getHeadSha($environment) . substr($sha, 4); } @@ -152,7 +145,7 @@ private function normalizeSha(Environment $environment, $sha = null) * * @return string */ - private function getHeadSha(Environment $environment) + private function getHeadSha(Environment $environment): string { if ($environment->head_commit === null) { throw new EnvironmentStateException('No commit(s) found. The environment is empty.', $environment); @@ -172,18 +165,23 @@ private function getHeadSha(Environment $environment) * @return string|false * The raw contents of the file, or false if the file is not found. */ - public function readFile($filename, Environment $environment, $commitSha = null) + public function readFile(string $filename, Environment $environment, ?string $commitSha = null): string|false { $commitSha = $this->normalizeSha($environment, $commitSha); $cacheKey = implode(':', ['raw', $environment->project, $filename, $commitSha]); $data = $this->cache->fetch($cacheKey); if (!is_array($data)) { $object = $this->getObject($filename, $environment, $commitSha); - $raw = $object ? $object->getRawContent() : false; - $data = ['raw' => $raw]; - // Skip caching if the file is bigger than 100 KiB. - if ($raw === false || strlen($raw) <= 102400) { - $this->cache->save($cacheKey, $data); + if ($object instanceof Tree) { + throw new GitObjectTypeException('The requested file is a directory', $filename); + } elseif ($object === false) { + $data['raw'] = false; + } else { + $data['raw'] = $object->getRawContent(); + // Cache the file if it is smaller than 100 KiB. + if (strlen($data['raw']) < 102400) { + $this->cache->save($cacheKey, $data); + } } } @@ -202,7 +200,7 @@ public function readFile($filename, Environment $environment, $commitSha = null) * @return Tree|Blob|false * The object or false if not found. */ - public function getObject($filename, Environment $environment, $commitSha = null) + public function getObject(string $filename, Environment $environment, ?string $commitSha = null): Tree|Blob|false { $commitSha = $this->normalizeSha($environment, $commitSha); $cacheKey = implode(':', ['obj', $environment->project, $filename, $commitSha]); @@ -231,13 +229,9 @@ public function getObject($filename, Environment $environment, $commitSha = null return $object; } - /** - * @param string $path - * @return string - */ - private function normalizePath($path) + private function normalizePath(string $path): string { - if (strpos($path, './') === 0) { + if (str_starts_with($path, './')) { $path = substr($path, 2); } $path = trim($path, '/'); @@ -254,7 +248,7 @@ private function normalizePath($path) * * @return Tree|false */ - public function getTree(Environment $environment, $path = '.', $commitSha = null, $onlyUseCache = false) + public function getTree(Environment $environment, string $path = '.', ?string $commitSha = null, bool $onlyUseCache = false): Tree|false { $normalizedSha = $this->normalizeSha($environment, $commitSha); $normalizedPath = $this->normalizePath($path); @@ -279,7 +273,7 @@ public function getTree(Environment $environment, $path = '.', $commitSha = null if (!$commit = $this->getCommit($environment, $normalizedSha)) { throw new \InvalidArgumentException(sprintf( 'Commit not found: %s', - $commitSha + $commitSha, )); } if (!$rootTree = $commit->getTree()) { diff --git a/src/Service/HostFactory.php b/src/Service/HostFactory.php new file mode 100644 index 0000000000..e88bb6af32 --- /dev/null +++ b/src/Service/HostFactory.php @@ -0,0 +1,24 @@ +shell); + } + + public function remote(string $sshUrl, Environment $environment): RemoteHost + { + return new RemoteHost($sshUrl, $environment, $this->ssh, $this->shell, $this->sshDiagnostics); + } +} diff --git a/src/Service/Identifier.php b/src/Service/Identifier.php index 8593a817c3..d7ad21a7db 100644 --- a/src/Service/Identifier.php +++ b/src/Service/Identifier.php @@ -1,4 +1,7 @@ config = $config ?: new Config(); $this->api = $api ?: new Api(); - $output = $output ?: new NullOutput(); - $this->stdErr = $output instanceof ConsoleOutput ? $output->getErrorOutput() : $output; $this->cache = $cache ?: CacheFactory::createCacheProvider($this->config); + $this->io = $io ?: new Io(new ConsoleOutput()); } /** - * Identify a project from an ID or URL. + * Identifies a project from an ID or URL. * * @param string $url * - * @return array - * An array containing keys 'projectId', 'environmentId', 'host', and - * 'appId'. At least the 'projectId' will be populated. + * @return array{projectId: string, environmentId: ?string, host: ?string, appId: ?string} */ - public function identify($url) + public function identify(string $url): array { $result = $this->parseProjectId($url); - if (empty($result['projectId']) && strpos($url, '.') !== false && $this->config->has('detection.cluster_header')) { + if (empty($result['projectId']) && str_contains($url, '.') && $this->config->has('detection.cluster_header')) { $result = $this->identifyFromHeaders($url); } if (empty($result['projectId'])) { @@ -65,9 +55,9 @@ public function identify($url) * @param string $url * A web UI, API, or public URL of the project. * - * @return array + * @return array{projectId?: string, environmentId?: string, appId?: string} */ - private function parseProjectId($url) + private function parseProjectId(string $url): array { $result = []; @@ -83,17 +73,17 @@ private function parseProjectId($url) return $result; } - $this->debug('Parsing URL to determine project ID: ' . $url); + $this->io->debug('Parsing URL to determine project ID: ' . $url); $host = $urlParts['host']; - $path = isset($urlParts['path']) ? $urlParts['path'] : ''; - $fragment = isset($urlParts['fragment']) ? $urlParts['fragment'] : ''; + $path = $urlParts['path'] ?? ''; + $fragment = $urlParts['fragment'] ?? ''; - $site_domains_pattern = '(' . implode('|', array_map('preg_quote', $this->config->get('detection.site_domains'))) . ')'; + $site_domains_pattern = '(' . implode('|', array_map('preg_quote', (array) $this->config->get('detection.site_domains'))) . ')'; $site_pattern = '/\-\w+\.[a-z]{2}(\-[0-9])?\.' . $site_domains_pattern . '$/'; if (preg_match($site_pattern, $host)) { - list($env_project_app,) = explode('.', $host, 2); + [$env_project_app, ] = explode('.', $host, 2); if (($tripleDashPos = strrpos($env_project_app, '---')) !== false) { $env_project_app = substr($env_project_app, $tripleDashPos + 3); } @@ -111,9 +101,9 @@ private function parseProjectId($url) return $result; } - if (strpos($path, '/projects/') !== false || strpos($fragment, '/projects/') !== false) { + if (str_contains($path, '/projects/') || str_contains($fragment, '/projects/')) { $result['host'] = $host; - $result['projectId'] = basename(preg_replace('#/projects(/\w+)/?.*$#', '$1', $url)); + $result['projectId'] = basename((string) preg_replace('#/projects(/\w+)/?.*$#', '$1', $url)); if (preg_match('#/environments(/[^/]+)/?.*$#', $url, $matches)) { $result['environmentId'] = rawurldecode(basename($matches[1])); } @@ -122,7 +112,7 @@ private function parseProjectId($url) } if ($this->config->has('detection.console_domain') - && $host === $this->config->get('detection.console_domain') + && $host === $this->config->getStr('detection.console_domain') && preg_match('#^/[a-z0-9-]+/([a-z0-9-]+)(/([^/]+))?#', $path, $matches) // Console uses /-/ to distinguish sub-paths and identifiers. && $matches[1] !== '-') { @@ -138,65 +128,59 @@ private function parseProjectId($url) } /** - * Identify a project and environment from a URL's response headers. - * - * @param string $url + * Identifies a project and environment from a URL's response headers. * - * @return array + * @return array{projectId: ?string, environmentId: ?string} */ - private function identifyFromHeaders($url) + private function identifyFromHeaders(string $url): array { if (!strpos($url, '.')) { throw new \InvalidArgumentException('Invalid URL: ' . $url); } - if (strpos($url, '//') === false) { + if (!str_contains($url, '//')) { $url = 'https://' . $url; } $result = ['projectId' => null, 'environmentId' => null]; $cluster = $this->getClusterHeader($url); if (!empty($cluster)) { - $this->debug('Identified project cluster: ' . $cluster); - list($result['projectId'], $result['environmentId']) = explode('-', $cluster, 2); + $this->io->debug('Identified project cluster: ' . $cluster); + [$result['projectId'], $result['environmentId']] = explode('-', $cluster, 2); } return $result; } /** - * Get a project cluster from its URL. - * - * @param string $url - * - * @return string|false + * Finds a project cluster from its URL. */ - private function getClusterHeader($url) + private function getClusterHeader(string $url): string|false { if (!$this->config->has('detection.cluster_header')) { return false; } $cacheKey = 'project-cluster:' . $url; - $cluster = $this->cache ? $this->cache->fetch($cacheKey) : false; + $cluster = $this->cache->fetch($cacheKey); if ($cluster === false) { - $this->debug('Making a HEAD request to identify project from URL: ' . $url); + $this->io->debug('Making a HEAD request to identify project from URL: ' . $url); try { $response = $this->api->getExternalHttpClient() ->head($url, [ - 'auth' => false, - 'timeout' => 5, - 'connect_timeout' => 5, - 'allow_redirects' => false, - ]); + 'auth' => false, + 'timeout' => 5, + 'connect_timeout' => 5, + 'allow_redirects' => false, + ]); } catch (RequestException $e) { // We can use a failed response, if one exists. if ($e->getResponse()) { $response = $e->getResponse(); } else { - $this->debug($e->getMessage()); + $this->io->debug($e->getMessage()); return false; } } - $cluster = $response->getHeaderAsArray($this->config->get('detection.cluster_header')); + $cluster = $response->getHeader($this->config->getStr('detection.cluster_header')); $canCache = !empty($cluster) || ($response->getStatusCode() >= 200 && $response->getStatusCode() < 300); if ($canCache) { @@ -206,12 +190,4 @@ private function getClusterHeader($url) return is_array($cluster) ? reset($cluster) : false; } - - /** - * @param string $message - */ - private function debug($message) - { - $this->stdErr->writeln('DEBUG ' . $message, OutputInterface::VERBOSITY_DEBUG); - } } diff --git a/src/Service/InputConfiguringInterface.php b/src/Service/InputConfiguringInterface.php index c00753729c..cc0171eeb3 100644 --- a/src/Service/InputConfiguringInterface.php +++ b/src/Service/InputConfiguringInterface.php @@ -1,10 +1,12 @@ osUtil = new OsUtil(); + } + + /** + * Returns whether other instances are installed of the CLI. + * + * Finds programs with the same executable name in the PATH. + */ + public function otherCLIsInstalled(): bool + { + if (self::$otherPaths === null) { + $thisPath = $this->cliPath(); + $paths = $this->osUtil->findExecutables($this->config->getStr('application.executable')); + self::$otherPaths = array_unique(array_filter($paths, function ($p) use ($thisPath): bool { + $realpath = realpath($p); + return $realpath && $realpath !== $thisPath; + })); + if (!empty(self::$otherPaths)) { + $this->io->debug('Other CLI(s) found: ' . implode(", ", self::$otherPaths)); + } + } + return !empty(self::$otherPaths); + } + + private function cliPath(): string + { + $path = CLI_ROOT . '/bin/platform'; + if (defined('CLI_FILE')) { + $path = CLI_FILE; + } + if (extension_loaded('Phar') && ($pharPath = \Phar::running(false))) { + $path = $pharPath; + } + return $path; + } +} diff --git a/src/Service/Io.php b/src/Service/Io.php new file mode 100644 index 0000000000..6c4b992721 --- /dev/null +++ b/src/Service/Io.php @@ -0,0 +1,77 @@ +stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; + } + + public function debug(string $message): void + { + if ($this->stdErr->isDebug()) { + $this->labeledMessage('DEBUG', $message); + } + } + + /** + * Print a message with a label. + * + * @param string $label + * @param string $message + * @param int $options + */ + private function labeledMessage(string $label, string $message, int $options = 0): void + { + $this->stdErr->writeln('' . strtoupper($label) . ' ' . $message, $options); + } + + /** + * Print a warning about deprecated option(s). + * + * @param string[] $options A list of option names (without "--"). + * @param string|null $template The warning message template. "%s" is + * replaced by the option name. + */ + public function warnAboutDeprecatedOptions(array $options, ?string $template = null): void + { + if (!isset($this->input)) { + return; + } + if ($template === null) { + $template = 'The option --%s is deprecated and no longer used. It will be removed in a future version.'; + } + foreach ($options as $option) { + if ($this->input->hasOption($option) && $this->input->getOption($option)) { + $this->labeledMessage( + 'DEPRECATED', + sprintf($template, $option), + ); + } + } + } + + /** + * Checks if running in a terminal. + * + * @param resource|int $descriptor + * + * @return bool + * + * @noinspection PhpMissingParamTypeInspection + */ + public function isTerminal($descriptor): bool + { + return !function_exists('posix_isatty') || posix_isatty($descriptor); + } +} diff --git a/src/Service/LegacyMigration.php b/src/Service/LegacyMigration.php new file mode 100644 index 0000000000..ad2f705a26 --- /dev/null +++ b/src/Service/LegacyMigration.php @@ -0,0 +1,215 @@ +stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; + } + + /** + * Check and prompt the user to remove the old PHP installation and migrate to the new Go-wrapped one. + * + * @return void + */ + public function checkMigrateToGoWrapper(): void + { + // Run migration steps if configured. + if ($this->config->getBool('migrate.prompt')) { + $this->promptDeleteOldCli(); + $this->checkMigrateToNewCLI(); + } + } + + /** + * Prompt the user to migrate from the legacy project file structure. + * + * If the input is interactive, the user will be asked to migrate up to once + * per hour. The time they were last asked will be stored in the project + * configuration. If the input is not interactive, the user will be warned + * (on every command run) that they should run the 'legacy-migrate' command. + */ + public function checkMigrateFrom3xTo4x(): void + { + static $asked = false; + if (!$this->localProject->getLegacyProjectRoot()) { + $asked = true; + return; + } + if ($asked) { + return; + } + $asked = true; + + $projectRoot = $this->localProject->getProjectRoot(); + $timestamp = time(); + $promptMigrate = true; + if ($projectRoot) { + $projectConfig = $this->localProject->getProjectConfig($projectRoot); + if (isset($projectConfig['migrate']['3.x']['last_asked']) + && $projectConfig['migrate']['3.x']['last_asked'] > $timestamp - 3600) { + $promptMigrate = false; + } + } + + $this->stdErr->writeln(sprintf( + 'You are in a project using an old file structure, from previous versions of the %s.', + $this->config->getStr('application.name'), + )); + if ($this->input->isInteractive() && $promptMigrate) { + if ($projectRoot && is_array($projectConfig)) { + $projectConfig['migrate']['3.x']['last_asked'] = $timestamp; + /** @noinspection PhpUnhandledExceptionInspection */ + $this->localProject->writeCurrentProjectConfig($projectConfig, $projectRoot); + } + if ($this->questionHelper->confirm('Migrate to the new structure?')) { + $code = $this->subCommandRunner->run('legacy-migrate'); + exit($code); + } + } else { + $this->stdErr->writeln(sprintf( + 'Fix this with: %s legacy-migrate', + $this->config->getStr('application.executable'), + )); + } + $this->stdErr->writeln(''); + } + + /** + * Check if both CLIs are installed to prompt the user to delete the old one. + */ + private function promptDeleteOldCli(): void + { + // Avoid checking more than once in this process. + if (self::$promptedDeleteOldCli) { + return; + } + self::$promptedDeleteOldCli = true; + + if ($this->config->isWrapped() || !$this->installationInfo->otherCLIsInstalled()) { + return; + } + $pharPath = \Phar::running(false); + if (!$pharPath || !is_file($pharPath) || !is_writable($pharPath)) { + return; + } + + // Avoid deleting random directories in path + $legacyDir = dirname($pharPath, 2); + if ($legacyDir !== $this->config->getUserConfigDir()) { + return; + } + + $message = "\nWarning: Multiple CLI instances are installed." + . "\nThis is probably due to migration between the Legacy CLI and the new CLI." + . "\nIf so, delete this (Legacy) CLI instance to complete the migration." + . "\n" + . "\nRemove the following file completely: $pharPath" + . "\nThis operation is safe and doesn't delete any data." + . "\n"; + $this->stdErr->writeln($message); + if ($this->questionHelper->confirm('Do you want to remove this file now?')) { + if (unlink($pharPath)) { + $this->stdErr->writeln('File successfully removed! Open a new terminal for the changes to take effect.'); + // Exit because no further Phar classes can be loaded. + // This uses a non-zero code because the original command + // technically failed. + exit(1); + } else { + $this->stdErr->writeln('Error: Failed to delete the file.'); + } + $this->stdErr->writeln(''); + } + } + + /** + * Check for migration to the new CLI. + */ + private function checkMigrateToNewCLI(): void + { + // Avoid checking more than once in this process. + if (self::$checkedMigrateToGoWrapper) { + return; + } + self::$checkedMigrateToGoWrapper = true; + + // Avoid if running within the new CLI or within a CI. + if ($this->config->isWrapped() || $this->isCI()) { + return; + } + + // Prompt the user to migrate at most once every 24 hours. + $now = time(); + $embargoTime = $now - $this->config->getWithDefault('migrate.prompt_interval', 60 * 60 * 24); + if ($this->state->get('migrate.last_prompted') > $embargoTime) { + return; + } + + $message = "Warning:" + . "\nRunning the CLI directly under PHP is now referred to as the \"Legacy CLI\", and is no longer recommended."; + if ($this->config->has('migrate.docs_url')) { + $message .= "\nInstall the latest release for your operating system by following these instructions: " + . "\n" . $this->config->getStr('migrate.docs_url'); + } + $message .= "\n"; + $this->stdErr->writeln($message); + $this->state->set('migrate.last_prompted', time()); + } + + /** + * Detects if running within a CI or local container system. + * + * @return bool + */ + private function isCI(): bool + { + return getenv('CI') !== false // GitHub Actions, Travis CI, CircleCI, Cirrus CI, GitLab CI, AppVeyor, CodeShip, dsari + || getenv('BUILD_NUMBER') !== false // Jenkins, TeamCity + || getenv('RUN_ID') !== false // TaskCluster, dsari + || getenv('LANDO_INFO') !== false // Lando (https://docs.lando.dev/guides/lando-info.html) + || getenv('IS_DDEV_PROJECT') === 'true' // DDEV (https://ddev.readthedocs.io/en/latest/users/extend/custom-commands/#environment-variables-provided) + || $this->detectRunningInHook(); // PSH + } + + /** + * Detects a Platform.sh non-terminal Dash environment; i.e. a hook. + * + * @todo deduplicate this + * + * @return bool + */ + private function detectRunningInHook(): bool + { + $envPrefix = $this->config->getStr('service.env_prefix'); + if (getenv($envPrefix . 'PROJECT') + && basename((string) getenv('SHELL')) === 'dash' + && !$this->io->isTerminal(STDIN)) { + return true; + } + + return false; + } +} diff --git a/src/Service/Login.php b/src/Service/Login.php new file mode 100644 index 0000000000..0831f0c9bb --- /dev/null +++ b/src/Service/Login.php @@ -0,0 +1,78 @@ +stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; + } + + /** + * Finalizes login: refreshes SSH certificate, prints account information. + */ + public function finalize(): void + { + // Reset the API client so that it will use the new tokens. + $this->api->getClient(false, true); + $this->stdErr->writeln('You are logged in.'); + + // Configure SSH host keys. + $this->sshConfig->configureHostKeys(); + + // Generate a new certificate from the certifier API. + if ($this->certifier->isAutoLoadEnabled() && $this->sshConfig->checkRequiredVersion()) { + $this->stdErr->writeln(''); + $this->stdErr->writeln('Generating SSH certificate...'); + try { + $this->certifier->generateCertificate(null); + $this->stdErr->writeln('A new SSH certificate has been generated.'); + $this->stdErr->writeln('It will be automatically refreshed when necessary.'); + } catch (\Exception $e) { + $this->stdErr->writeln('Failed to generate SSH certificate: ' . $e->getMessage() . ''); + } + } + + // Write SSH configuration. + if ($this->sshConfig->configureSessionSsh()) { + $this->sshConfig->addUserSshConfig($this->questionHelper); + } + + // Show user account info. + $account = $this->api->getMyAccount(true); + $this->stdErr->writeln(sprintf( + "\nUsername: %s\nEmail address: %s", + $account['username'], + $account['email'], + )); + } + + /** + * Get help on how to use API tokens non-interactively. + * + * @param string $tag + * + * @return string + */ + public function getNonInteractiveAuthHelp(string $tag = 'info'): string + { + $prefix = $this->config->getStr('application.env_prefix'); + + return "To authenticate non-interactively, configure an API token using the <$tag>{$prefix}TOKEN environment variable."; + } +} diff --git a/src/Service/Mount.php b/src/Service/Mount.php index 0b1fddde6d..21322b5850 100644 --- a/src/Service/Mount.php +++ b/src/Service/Mount.php @@ -1,4 +1,7 @@ $mounts + * Mounts, from the app configuration. * - * @return array + * @return array * An array of shared file paths, keyed by the mount path. Leading and * trailing slashes are stripped. An empty shared path defaults to * 'files'. */ - public function getSharedFileMounts(array $mounts) + public function getSharedFileMounts(array $mounts): array { $sharedFileMounts = []; foreach ($this->normalizeMounts($mounts) as $path => $definition) { @@ -38,10 +41,10 @@ public function getSharedFileMounts(array $mounts) * * @param AppConfig $appConfig * - * @return array + * @return array * A normalized list of mounts. */ - public function mountsFromConfig(AppConfig $appConfig) + public function mountsFromConfig(AppConfig $appConfig): array { $config = $appConfig->getNormalized(); if (empty($config['mounts'])) { @@ -58,11 +61,11 @@ public function mountsFromConfig(AppConfig $appConfig) * the mount definition is in the newer structured format, with a 'source' * and probably a 'source_path'. * - * @param array $mounts + * @param array $mounts * - * @return array + * @return array */ - public function normalizeMounts(array $mounts) + public function normalizeMounts(array $mounts): array { $normalized = []; foreach ($mounts as $path => $definition) { @@ -76,14 +79,14 @@ public function normalizeMounts(array $mounts) * Checks that a given path matches a mount in the list. * * @param string $path - * @param array $mounts - * - * @throws \InvalidArgumentException if the path does not match + * @param array $mounts * * @return string * If the $path matches, the normalized path is returned. + *@throws \InvalidArgumentException if the path does not match + * */ - public function matchMountPath($path, array $mounts) + public function matchMountPath(string $path, array $mounts): string { $normalized = $this->normalizeRelativePath($path); if (isset($mounts[$normalized])) { @@ -100,23 +103,22 @@ public function matchMountPath($path, array $mounts) * * @return string */ - private function normalizeRelativePath($path) + private function normalizeRelativePath(string $path): string { return trim(trim($path), '/'); } /** - * Normalize a mount definition. + * Normalizes a mount definition. * - * @param array|string $definition + * @param array{source?: string, source_path?: string}|string $definition * - * @return array - * An array containing at least 'source', and probably 'source_path'. + * @return array{source: string, source_path?: string} */ - private function normalizeDefinition($definition) + private function normalizeDefinition(array|string $definition): array { if (!is_array($definition)) { - if (!is_string($definition) || strpos($definition, 'shared:files') === false) { + if (!str_contains($definition, 'shared:files')) { throw new \RuntimeException('Failed to parse mount definition: ' . json_encode($definition)); } $definition = [ diff --git a/src/Service/ProjectSshInfo.php b/src/Service/ProjectSshInfo.php new file mode 100644 index 0000000000..98f63cf6af --- /dev/null +++ b/src/Service/ProjectSshInfo.php @@ -0,0 +1,20 @@ +ssh->hostIsInternal($project->getGitUrl()) === false; + } +} diff --git a/src/Service/PropertyFormatter.php b/src/Service/PropertyFormatter.php index 91dfa94e20..8317b08436 100644 --- a/src/Service/PropertyFormatter.php +++ b/src/Service/PropertyFormatter.php @@ -1,8 +1,11 @@ input = $input; $this->config = $config ?: new Config(); } - /** - * @param mixed $value - * @param string $property - * - * @return string - */ - public function format($value, $property = null) + public function format(mixed $value, ?string $property = null): string { if ($value === null && $property !== 'parent') { return ''; @@ -77,17 +69,18 @@ public function format($value, $property = null) break; case 'service_type': - if (substr_count($value, ':') === 2) { - $value = substr($value, 0, strrpos($value, ':')); + if (is_string($value) && substr_count($value, ':') === 2) { + [$stack, $version,] = explode(':', $value); + $value = $stack . ':' . $version; } break; } if (!is_string($value) && !is_float($value)) { - $value = rtrim(Yaml::dump($value, 2)); + $value = rtrim(Yaml::dump($value)); } - return $value; + return (string) $value; } /** @@ -95,7 +88,7 @@ public function format($value, $property = null) * * @param InputDefinition $definition */ - public static function configureInput(InputDefinition $definition) + public static function configureInput(InputDefinition $definition): void { static $config; $config = $config ?: new Config(); @@ -104,61 +97,58 @@ public static function configureInput(InputDefinition $definition) null, InputOption::VALUE_REQUIRED, 'The date format (as a PHP date format string)', - $config->getWithDefault('application.date_format', 'c') + $config->getStr('application.date_format'), )); } /** * Returns the configured date format. - * - * @return string */ - private function dateFormat() + private function dateFormat(): string { if (isset($this->input) && $this->input->hasOption('date-fmt')) { return $this->input->getOption('date-fmt'); } - return $this->config->getWithDefault('application.date_format', 'c'); + return $this->config->getStr('application.date_format'); } /** * Formats a string datetime. * - * @param string $value + * @param int|string $value * - * @return string|null + * @return string + * @throws \Exception if the date is malformed. */ - public function formatDate($value) + public function formatDate(int|string $value): string { - // Workaround for the ssl.expires_on date, which is currently a - // timestamp in milliseconds. - if (substr($value, -3) === '000' && strlen($value) === 13) { - $value = substr($value, 0, 10); + if (is_numeric($value)) { + $dateTime = new \DateTime(); + $dateTime->setTimestamp((int) $value); + } else { + $dateTime = new \DateTime($value); } + $dateTime->setTimezone(new \DateTimeZone( + $this->config->getWithDefault('application.timezone', null) + ?: TimezoneUtil::getTimezone(), + )); - $timestamp = is_numeric($value) ? $value : strtotime($value); - - return $timestamp === false ? null : date($this->dateFormat(), $timestamp); + return $dateTime->format($this->dateFormat()); } /** * Formats a UNIX timestamp. - * - * @param int $value - * - * @return string */ - public function formatUnixTimestamp($value) + public function formatUnixTimestamp(int $value): string { return date($this->dateFormat(), $value); } /** - * @param array|string|null $httpAccess - * + * @param array|string|null $httpAccess * @return string */ - protected function formatHttpAccess($httpAccess) + protected function formatHttpAccess(array|string|null $httpAccess): string { $info = (array) $httpAccess; $info += [ @@ -167,22 +157,20 @@ protected function formatHttpAccess($httpAccess) 'is_enabled' => true, ]; // Hide passwords. - $info['basic_auth'] = array_map(function () { - return '******'; - }, $info['basic_auth']); + $info['basic_auth'] = array_map(fn(): string => '******', $info['basic_auth']); return $this->format($info); } /** - * Display a complex data structure. + * Displays a complex data structure. * - * @param OutputInterface $output An output object. - * @param array $data The data to display. - * @param string|null $property The property of the data to display - * (a dot-separated string). + * @param OutputInterface $output An output object. + * @param array $data The data to display. + * @param string|null $property The property of the data to display + * (a dot-separated string). */ - public function displayData(OutputInterface $output, array $data, $property = null) + public function displayData(OutputInterface $output, array $data, ?string $property = null): void { $key = null; diff --git a/src/Service/QuestionHelper.php b/src/Service/QuestionHelper.php index a6729bee58..ef5a6f9986 100644 --- a/src/Service/QuestionHelper.php +++ b/src/Service/QuestionHelper.php @@ -1,4 +1,7 @@ input = $input; if ($output instanceof ConsoleOutputInterface) { $output = $output->getErrorOutput(); } @@ -47,13 +46,13 @@ public function __construct(InputInterface $input, OutputInterface $output) * * @return bool */ - public function confirm($questionText, $default = true) + public function confirm(string $questionText, bool $default = true): bool { if (!$this->input->isInteractive() && !$default) { trigger_error( 'A confirmation question is being asked with a default of "no" (false), despite being in non-interactive mode.' . ' So --yes will not pick the default result for this question, which is confusing.', - E_USER_WARNING + E_USER_WARNING, ); } @@ -79,18 +78,18 @@ public function confirm($questionText, $default = true) /** * Provides an interactive choice question. * - * @param array $items An associative array of choices. - * @param string $text Some text to precede the choices. - * @param mixed $default A default (as a key in $items). - * @param bool $skipOnOne Whether to skip the choice if there is only one + * @param array $items An associative array of choices. + * @param string $text Some text to precede the choices. + * @param string|null $default A default (as a key in $items). + * @param bool $skipOnOne Whether to skip the choice if there is only one * item. * - * @throws \RuntimeException on failure - * - * @return int|string|null + * @return string * The chosen item (as a key in $items). + * + * @throws \RuntimeException on failure */ - public function choose(array $items, $text = 'Enter a number to choose an item:', $default = null, $skipOnOne = true) + public function choose(array $items, string $text = 'Enter a number to choose an item:', ?string $default = null, bool $skipOnOne = true): string { if (count($items) === 1) { if ($skipOnOne) { @@ -112,7 +111,7 @@ public function choose(array $items, $text = 'Enter a number to choose an item:' if (!$this->input->isInteractive()) { if (!isset($defaultKey)) { - return null; + throw new \RuntimeException('A choice is needed, input is not interactive, and no default is available.'); } $choice = $itemList[$defaultKey]; $choiceKey = array_search($choice, $items, true); @@ -137,19 +136,19 @@ public function choose(array $items, $text = 'Enter a number to choose an item:' /** * Provides an interactive choice question preserving the array keys. * - * @param array $items An associative array of choices. + * @param array $items An associative array of choices. * @param string $text Some text to precede the choices. - * @param mixed $default A default (as a key in $items). - * @param bool $skipOnOne Whether to skip the choice if there is only one + * @param string|null $default A default (as a key in $items). + * @param bool $skipOnOne Whether to skip the choice if there is only one * item. - * @param bool $newLine Whether to output a newline after asking the question. + * @param bool $newLine Whether to output a newline after asking the question. * - * @throws \RuntimeException on failure - * - * @return int|string|null + * @return string * The chosen item (as a key in $items). + *@throws \RuntimeException on failure + * */ - public function chooseAssoc(array $items, $text = 'Choose an item:', $default = null, $skipOnOne = true, $newLine = true) + public function chooseAssoc(array $items, string $text = 'Choose an item:', ?string $default = null, bool $skipOnOne = true, bool $newLine = true): string { if (count($items) === 1) { if ($skipOnOne) { @@ -170,16 +169,15 @@ public function chooseAssoc(array $items, $text = 'Choose an item:', $default = /** * Ask a simple question which requires input. * - * @param string $questionText - * @param mixed $default - * @param array $autoCompleterValues - * @param callable $validator - * @param string $defaultLabel - * - * @return string + * @param string $questionText + * @param mixed|null $default + * @param string[] $autoCompleterValues + * @param callable|null $validator + * @param string $defaultLabel + * @return mixed * The user's answer. */ - public function askInput($questionText, $default = null, array $autoCompleterValues = [], callable $validator = null, $defaultLabel = 'default: ') + public function askInput(string $questionText, mixed $default = null, array $autoCompleterValues = [], ?callable $validator = null, string $defaultLabel = 'default: '): mixed { if ($default !== null) { $questionText .= sprintf(' (%s%s)', $defaultLabel, $default); diff --git a/src/Service/Relationships.php b/src/Service/Relationships.php index 9e18d8a693..6adebb9444 100644 --- a/src/Service/Relationships.php +++ b/src/Service/Relationships.php @@ -1,8 +1,11 @@ envVarService = $envVarService; - } + public function __construct(protected RemoteEnvVars $envVarService) {} /** - * @param \Symfony\Component\Console\Input\InputDefinition $definition + * @param InputDefinition $definition */ - public static function configureInput(InputDefinition $definition) + public static function configureInput(InputDefinition $definition): void { $definition->addOption( - new InputOption('relationship', 'r', InputOption::VALUE_REQUIRED, 'The service relationship to use') + new InputOption('relationship', 'r', InputOption::VALUE_REQUIRED, 'The service relationship to use'), ); } /** * Choose a database for the user. * - * @param HostInterface $host - * @param InputInterface $input - * @param OutputInterface $output + * @param string[] $types A service type filter. * - * @return array|false + * @return false|array */ - public function chooseDatabase(HostInterface $host, InputInterface $input, OutputInterface $output, $types = ['mysql', 'pgsql']) + public function chooseDatabase(HostInterface $host, InputInterface $input, OutputInterface $output, array $types = ['mysql', 'pgsql']): false|array { return $this->chooseService($host, $input, $output, $types); } @@ -58,16 +53,25 @@ public function chooseDatabase(HostInterface $host, InputInterface $input, Outpu * @param OutputInterface $output * @param string[] $schemes Filter by scheme. * - * @return array|false + * @return false|array{ + * scheme: string, + * username: string, + * password: string, + * host: string, + * port:int, + * path: string, + * _relationship_name: string, + * _relationship_key: string, + * } */ - public function chooseService(HostInterface $host, InputInterface $input, OutputInterface $output, $schemes = []) + public function chooseService(HostInterface $host, InputInterface $input, OutputInterface $output, array $schemes = []): array|false { $stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; $relationships = $this->getRelationships($host); // Filter to find services matching the schemes. if (!empty($schemes)) { - $relationships = array_filter($relationships, function (array $relationship) use ($schemes) { + $relationships = array_filter($relationships, function (array $relationship) use ($schemes): bool { foreach ($relationship as $service) { if (isset($service['scheme']) && in_array($service['scheme'], $schemes, true)) { return true; @@ -82,7 +86,7 @@ public function chooseService(HostInterface $host, InputInterface $input, Output if (!empty($schemes)) { $stdErr->writeln(sprintf('No relationships found matching scheme(s): %s.', implode(', ', $schemes))); } else { - $stdErr->writeln(sprintf('No relationships found')); + $stdErr->writeln('No relationships found'); } return false; } @@ -108,9 +112,9 @@ public function chooseService(HostInterface $host, InputInterface $input, Output if ($input->hasOption('relationship') && ($relationshipName = $input->getOption('relationship'))) { // Normalise the relationship name to remove a trailing ".0". - if (substr($relationshipName, -2) === '.0' - && isset($relationships[$relationshipName]) && count($relationships[$relationshipName]) ===1) { - $relationshipName = substr($relationshipName, 0, strlen($relationshipName) - 2); + if (str_ends_with((string) $relationshipName, '.0') + && isset($relationships[$relationshipName]) && count($relationships[$relationshipName]) === 1) { + $relationshipName = substr((string) $relationshipName, 0, strlen((string) $relationshipName) - 2); } if (!isset($choices[$relationshipName])) { $stdErr->writeln('Relationship not found: ' . $relationshipName . ''); @@ -139,8 +143,8 @@ public function chooseService(HostInterface $host, InputInterface $input, Output $identifier = $questionHelper->choose($choices, 'Enter a number to choose a relationship:'); } - if (strpos($identifier, '.') !== false) { - list($name, $key) = explode('.', $identifier, 2); + if (str_contains((string) $identifier, '.')) { + [$name, $key] = explode('.', (string) $identifier, 2); } else { $name = $identifier; $key = 0; @@ -153,7 +157,7 @@ public function chooseService(HostInterface $host, InputInterface $input, Output if (!isset($relationship['service'])) { $appConfig = $this->envVarService->getArrayEnvVar('APPLICATION', $host); if (!empty($appConfig['relationships'][$name]) && is_string($appConfig['relationships'][$name])) { - list($serviceName, ) = explode(':', $appConfig['relationships'][$name], 2); + [$serviceName, ] = explode(':', $appConfig['relationships'][$name], 2); $relationship['service'] = $serviceName; } } @@ -171,12 +175,12 @@ public function chooseService(HostInterface $host, InputInterface $input, Output * @param HostInterface $host * @param bool $refresh * - * @return array + * @return array */ - public function getRelationships(HostInterface $host, $refresh = false) + public function getRelationships(HostInterface $host, bool $refresh = false): array { return $this->normalizeRelationships( - $this->envVarService->getArrayEnvVar('RELATIONSHIPS', $host, $refresh) + $this->envVarService->getArrayEnvVar('RELATIONSHIPS', $host, $refresh), ); } @@ -185,11 +189,11 @@ public function getRelationships(HostInterface $host, $refresh = false) * * If only real-life relationships were this simple. * - * @param array $relationships + * @param array $relationships * - * @return array + * @return array */ - private function normalizeRelationships(array $relationships) + private function normalizeRelationships(array $relationships): array { foreach ($relationships as &$relationship) { foreach ($relationship as &$instance) { @@ -199,12 +203,10 @@ private function normalizeRelationships(array $relationships) // first hostname), and parses that to populate the instance // definition. if (isset($instance['scheme']) && isset($instance['host']) - && $instance['scheme'] === 'mongodb' && strpos($instance['host'], 'mongodb://') === 0) { + && $instance['scheme'] === 'mongodb' && str_starts_with((string) $instance['host'], 'mongodb://')) { $mongodbUri = $instance['host']; - $url = \preg_replace_callback('#^(mongodb://)([^/?]+)([/?]|$)#', function ($matches) { - return $matches[1] . \explode(',', $matches[2])[0] . $matches[3]; - }, $mongodbUri); - $urlParts = \parse_url($url); + $url = \preg_replace_callback('#^(mongodb://)([^/?]+)([/?]|$)#', fn($matches): string => $matches[1] . \explode(',', (string) $matches[2])[0] . $matches[3], (string) $mongodbUri); + $urlParts = \parse_url((string) $url); if ($urlParts) { $instance = array_merge($urlParts, $instance); // Fix the "host" to be a hostname. @@ -221,12 +223,12 @@ private function normalizeRelationships(array $relationships) /** * Returns whether the database is MariaDB. * - * @param array $database The database definition from the relationships. + * @param array $database The database definition from the relationships. * @return bool */ - public function isMariaDB(array $database) + public function isMariaDB(array $database): bool { - return isset($database['type']) && (\strpos($database['type'], 'mariadb:') === 0 || \strpos($database['type'], 'mysql:') === 0); + return isset($database['type']) && (str_starts_with((string) $database['type'], 'mariadb:') || str_starts_with((string) $database['type'], 'mysql:')); } /** @@ -241,7 +243,7 @@ public function isMariaDB(array $database) * * @return string */ - public function mariaDbCommandWithFallback($cmd) + public function mariaDbCommandWithFallback(string $cmd): string { if ($cmd === 'mariadb') { return 'cmd="$(command -v mariadb || echo -n mysql)"; "$cmd"'; @@ -255,21 +257,19 @@ public function mariaDbCommandWithFallback($cmd) /** * Returns command-line arguments to connect to a database. * - * @param string $command The command that will need arguments - * (one of 'psql', 'pg_dump', 'mysql', - * 'mysqldump', 'mariadb' or - * 'mariadb-dump'). - * @param array $database The database definition from the - * relationship. - * @param string|null $schema The name of a database schema, or - * null to use the default schema, or - * an empty string to not select a - * schema. + * @param string $command + * The command that will need arguments (one of 'psql', 'pg_dump', + * 'mysql', mysqldump', 'mariadb' or 'mariadb-dump'). + * @param array $database + * The database definition from the relationship. + * @param string|null $schema + * The name of a database schema, null to use the default schema, or an + * empty string to not select a schema. * * @return string * The command line arguments (excluding the $command). */ - public function getDbCommandArgs($command, array $database, $schema = null) + public function getDbCommandArgs(string $command, array $database, ?string $schema = null): string { if ($schema === null) { $schema = $database['path']; @@ -283,10 +283,10 @@ public function getDbCommandArgs($command, array $database, $schema = null) $database['username'], $database['password'], $database['host'], - $database['port'] + $database['port'], ); if ($schema !== '') { - $url .= '/' . rawurlencode($schema); + $url .= '/' . rawurlencode((string) $schema); } return OsUtil::escapePosixShellArg($url); @@ -300,7 +300,7 @@ public function getDbCommandArgs($command, array $database, $schema = null) OsUtil::escapePosixShellArg($database['username']), OsUtil::escapePosixShellArg($database['password']), OsUtil::escapePosixShellArg($database['host']), - $database['port'] + $database['port'], ); if ($schema !== '') { $args .= ' ' . OsUtil::escapePosixShellArg($schema); @@ -323,7 +323,7 @@ public function getDbCommandArgs($command, array $database, $schema = null) OsUtil::escapePosixShellArg($database['username']), OsUtil::escapePosixShellArg($database['password']), OsUtil::escapePosixShellArg($database['host']), - $database['port'] + $database['port'], ); if ($schema !== '') { $args .= ' --authenticationDatabase ' . OsUtil::escapePosixShellArg($schema); @@ -343,7 +343,7 @@ public function getDbCommandArgs($command, array $database, $schema = null) /** * @return bool */ - public function hasLocalEnvVar() + public function hasLocalEnvVar(): bool { return $this->envVarService->getEnvVar('RELATIONSHIPS', new LocalHost()) !== ''; } @@ -351,11 +351,11 @@ public function hasLocalEnvVar() /** * Builds a URL from the parts included in a relationship array. * - * @param array $instance + * @param array $instance * * @return string */ - public function buildUrl(array $instance) + public function buildUrl(array $instance): string { $parts = $instance; @@ -370,13 +370,13 @@ public function buildUrl(array $instance) // The 'query' is expected to be a string. if (isset($parts['query']) && is_array($parts['query'])) { unset($parts['query']['is_master']); - $parts['query'] = (new Query($parts['query']))->__toString(); + $parts['query'] = Query::build($parts['query']); } // Special case #1: Solr. if (isset($parts['scheme']) && $parts['scheme'] === 'solr') { $parts['scheme'] = 'http'; - if (isset($parts['path']) && \dirname($parts['path']) === '/solr') { + if (isset($parts['path']) && \dirname((string) $parts['path']) === '/solr') { $parts['path'] = '/solr/'; } } @@ -385,7 +385,7 @@ public function buildUrl(array $instance) $parts['scheme'] = 'postgresql'; } - return \GuzzleHttp\Url::buildUrl($parts); + return Uri::fromParts($parts)->__toString(); } /** @@ -402,7 +402,7 @@ public function buildUrl(array $instance) * * @return string[] */ - public function getServiceSchemas(Service $service) + public function getServiceSchemas(Service $service): array { if (!empty($service->configuration['schemas'])) { return $service->configuration['schemas']; diff --git a/src/Service/RemoteEnvVars.php b/src/Service/RemoteEnvVars.php index d635195796..b5dd54ece8 100644 --- a/src/Service/RemoteEnvVars.php +++ b/src/Service/RemoteEnvVars.php @@ -1,5 +1,7 @@ ssh = $ssh; - $this->cache = $cache; - $this->config = $config; - } + public function __construct(protected Ssh $ssh, protected CacheProvider $cache, protected Config $config) {} /** * Read an environment variable from a remote application. @@ -44,9 +36,9 @@ public function __construct(Ssh $ssh, CacheProvider $cache, Config $config) * * @return string The environment variable or an empty string. */ - public function getEnvVar($variable, HostInterface $host, $refresh = false, $ttl = 3600) + public function getEnvVar(string $variable, HostInterface $host, bool $refresh = false, int $ttl = 3600): string { - $varName = $this->config->get('service.env_prefix') . $variable; + $varName = $this->config->getStr('service.env_prefix') . $variable; if ($host instanceof LocalHost) { return getenv($varName) !== false ? getenv($varName) : ''; } @@ -79,13 +71,13 @@ public function getEnvVar($variable, HostInterface $host, $refresh = false, $ttl * @param HostInterface $host * @param bool $refresh * - * @return array - * @see \Platformsh\Cli\Service\RemoteEnvVars::getEnvVar() + * @return array + * @see RemoteEnvVars::getEnvVar */ - public function getArrayEnvVar($variable, HostInterface $host, $refresh = false) + public function getArrayEnvVar(string $variable, HostInterface $host, bool $refresh = false): array { $value = $this->getEnvVar($variable, $host, $refresh); - return json_decode(base64_decode($value), true) ?: []; + return json_decode((string) base64_decode($value), true) ?: []; } } diff --git a/src/Service/ResourcesUtil.php b/src/Service/ResourcesUtil.php new file mode 100644 index 0000000000..0c89f65a1f --- /dev/null +++ b/src/Service/ResourcesUtil.php @@ -0,0 +1,276 @@ + */ + private static array $cachedNextDeployment = []; + + public function __construct(private readonly Api $api, private readonly Config $config, OutputInterface $output) + { + $this->stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; + } + + public function featureEnabled(): bool + { + return $this->config->getBool('api.sizing'); + } + + /** + * Lists services in a deployment. + * + * @param EnvironmentDeployment $deployment + * + * @return array + * An array of services keyed by the service name. + */ + public function allServices(EnvironmentDeployment $deployment): array + { + $webapps = $deployment->webapps; + $workers = $deployment->workers; + $services = $deployment->services; + ksort($webapps, SORT_STRING | SORT_FLAG_CASE); + ksort($workers, SORT_STRING | SORT_FLAG_CASE); + ksort($services, SORT_STRING | SORT_FLAG_CASE); + return array_merge($webapps, $workers, $services); + } + + /** + * Checks whether a service needs a persistent disk. + */ + public function supportsDisk(WebApp|Worker|Service $service): bool + { + // Workers use the disk of their parent app. + if ($service instanceof Worker) { + return false; + } + return isset($service->getProperties()['resources']['minimum']['disk']); + } + + /** + * Loads the next environment deployment and caches it statically. + * + * The static cache means it can be reused while running a sub-command. + */ + public function loadNextDeployment(Environment $environment, bool $reset = false): EnvironmentDeployment + { + $cacheKey = $environment->project . ':' . $environment->id; + if (isset(self::$cachedNextDeployment[$cacheKey]) && !$reset) { + return self::$cachedNextDeployment[$cacheKey]; + } + $progress = new ProgressMessage($this->stdErr); + try { + $progress->show('Loading deployment information...'); + $next = $environment->getNextDeployment(); + if (!$next) { + throw new EnvironmentStateException('No next deployment found', $environment); + } + } finally { + $progress->done(); + } + return self::$cachedNextDeployment[$cacheKey] = $next; + } + + /** + * Filters a list of services according to the --service or --type options. + * + * @param array $services + * @param InputInterface $input + * + * @return WebApp[]|Service[]|Worker[]|false + * False on error, or an array of services. + */ + public function filterServices(array $services, InputInterface $input): array|false + { + $selectedNames = []; + + $requestedServices = ArrayArgument::getOption($input, 'service'); + if (!empty($requestedServices)) { + $selectedNames = Wildcard::select(array_keys($services), $requestedServices); + if (!$selectedNames) { + $this->stdErr->writeln('No services were found matching the name(s): ' . implode(', ', $requestedServices) . ''); + return false; + } + $services = array_intersect_key($services, array_flip($selectedNames)); + } + $requestedApps = ArrayArgument::getOption($input, 'app'); + if (!empty($requestedApps)) { + $selectedNames = Wildcard::select(array_keys(array_filter($services, fn($s): bool => $s instanceof WebApp)), $requestedApps); + if (!$selectedNames) { + $this->stdErr->writeln('No applications were found matching the name(s): ' . implode(', ', $requestedApps) . ''); + return false; + } + $services = array_intersect_key($services, array_flip($selectedNames)); + } + $requestedWorkers = ArrayArgument::getOption($input, 'worker'); + if (!empty($requestedWorkers)) { + $selectedNames = Wildcard::select(array_keys(array_filter($services, fn($s): bool => $s instanceof Worker)), $requestedWorkers); + if (!$selectedNames) { + $this->stdErr->writeln('No workers were found matching the name(s): ' . implode(', ', $requestedWorkers) . ''); + return false; + } + $services = array_intersect_key($services, array_flip($selectedNames)); + } + + if ($input->hasOption('type') && ($requestedTypes = ArrayArgument::getOption($input, 'type'))) { + $byType = []; + foreach ($services as $name => $service) { + $type = $service->type; + [$prefix] = explode(':', $service->type, 2); + $byType[$type][] = $name; + $byType[$prefix][] = $name; + } + $selectedTypes = Wildcard::select(array_keys($byType), $requestedTypes); + if (!$selectedTypes) { + $this->stdErr->writeln('No services were found matching the type(s): ' . implode(', ', $requestedTypes) . ''); + return false; + } + foreach ($selectedTypes as $selectedType) { + $selectedNames = array_merge($selectedNames, $byType[$selectedType]); + } + $services = array_intersect_key($services, array_flip($selectedNames)); + } + + return $services; + } + + /** + * Returns container profile size info, given service properties. + * + * @param array $properties + * The service properties (e.g. from $service->getProperties()). + * @param array $containerProfiles + * The list of container profiles (e.g. from + * $deployment->container_profiles). + * + * @return array{'cpu': string, 'memory': string}|null + */ + public function sizeInfo(array $properties, array $containerProfiles): ?array + { + if (isset($properties['resources']['profile_size'])) { + $size = $properties['resources']['profile_size']; + $profile = $properties['container_profile']; + if (isset($containerProfiles[$profile][$size])) { + return $containerProfiles[$profile][$size]; + } + } + return null; + } + + /** + * Formats a change in a value. + * + * @param int|float|string|null $previousValue + * @param int|float|string|null $newValue + * @param string $suffix A unit suffix e.g. ' MB' + * + * @return string + */ + public function formatChange(int|float|string|null $previousValue, int|float|string|null $newValue, string $suffix = ''): string + { + if ($previousValue === null || $newValue === $previousValue) { + return sprintf('%s%s', $newValue, $suffix); + } + return sprintf( + '%s from %s%s to %s%s', + $newValue > $previousValue ? 'increasing' : 'decreasing', + $previousValue, + $suffix, + $newValue, + $suffix, + ); + } + + /** + * Formats a CPU amount. + * + * @param int|float|string $unformatted + * + * @return string + * A numeric (still comparable) string with 1 decimal place. + */ + public function formatCPU(int|float|string $unformatted): string + { + return sprintf('%.1f', $unformatted); + } + + /** + * Adds a --resources-init option to commands that support it. + * + * The option will only be added if the api.sizing feature is enabled. + * + * @param InputDefinition $definition + * @param string[] $values + * The possible values, with the default as the first element. + * @param string $description + * + * @return self + * + * @see ResourcesUtil::validateResourcesInitInput() + */ + public function addOption(InputDefinition $definition, array $values, string $description = ''): self + { + if (!$this->featureEnabled()) { + return $this; + } + if ($description === '') { + $description = 'Set the resources to use for new services'; + $description .= ': ' . StringUtil::formatItemList($values); + $default = array_shift($values); + $description .= ".\n" . sprintf('If not set, "%s" will be used.', $default); + } + $definition->addOption(new InputOption('resources-init', null, InputOption::VALUE_REQUIRED, $description)); + + return $this; + } + + /** + * Validates and returns the --resources-init input, if any. + * + * @param InputInterface $input + * @param Project $project + * @param string[] $allowedValues + * @return string|false|null + * The input value, or false if there was a validation error, or null if + * nothing was specified or the input option didn't exist. + * + * @see ResourcesUtil::addResourcesInitOption() + */ + public function validateInput(InputInterface $input, Project $project, array $allowedValues): false|string|null + { + $resourcesInit = $input->hasOption('resources-init') ? $input->getOption('resources-init') : null; + if ($resourcesInit !== null) { + if (!\in_array($resourcesInit, $allowedValues, true)) { + $this->stdErr->writeln('The value for --resources-init must be one of: ' . \implode(', ', $allowedValues)); + return false; + } + if (!$this->api->supportsSizingApi($project)) { + $this->stdErr->writeln('The --resources-init option cannot be used as the project does not support flexible resources.'); + return false; + } + } + return $resourcesInit; + } +} diff --git a/src/Service/Rsync.php b/src/Service/Rsync.php index 388f22fe2e..fcce9bbd1f 100644 --- a/src/Service/Rsync.php +++ b/src/Service/Rsync.php @@ -1,5 +1,7 @@ shell = $shellHelper; - $this->ssh = $ssh; - $this->sshDiagnostics = $sshDiagnostics; - } + public function __construct(private Shell $shell, private Ssh $ssh, private SshDiagnostics $sshDiagnostics) {} /** * Returns environment variables for configuring rsync. * - * @param string $sshUrl - * - * @return array + * @return array */ - private function env($sshUrl) { + private function env(string $sshUrl): array + { return [ - 'RSYNC_RSH' => $this->ssh->getSshCommand($sshUrl, [], null, true), + 'RSYNC_RSH' => $this->ssh->getSshCommand($sshUrl, omitUrl: true), ] + $this->ssh->getEnv(); } @@ -46,13 +37,13 @@ private function env($sshUrl) { * * @return bool|null */ - public function supportsConvertingFilenames() + public function supportsConvertingFilenames(): ?bool { static $supportsIconv; if (!isset($supportsIconv)) { $result = $this->shell->execute(['rsync', '-h']); if (is_string($result)) { - $supportsIconv = strpos($result, '--iconv') !== false; + $supportsIconv = str_contains($result, '--iconv'); } } @@ -62,12 +53,9 @@ public function supportsConvertingFilenames() /** * Syncs files from a local to a remote location. * - * @param string $sshUrl - * @param string $localDir - * @param string $remoteDir - * @param array $options + * @param array{verbose?: bool, quiet?: bool, convert-mac-filenames?: bool, delete?: bool, include?: string[], exclude?: string[]} $options */ - public function syncUp($sshUrl, $localDir, $remoteDir, array $options = []) + public function syncUp(string $sshUrl, string $localDir, string $remoteDir, array $options = []): void { // Ensure a trailing slash on the "from" path, to copy the directory's // contents rather than the directory itself. @@ -82,14 +70,11 @@ public function syncUp($sshUrl, $localDir, $remoteDir, array $options = []) } /** - * Syncs files from a remote to a local location. + * Syncs files from a remote to a local location * - * @param string $sshUrl - * @param string $remoteDir - * @param string $localDir - * @param array $options + * @param array{verbose?: bool, quiet?: bool, convert-mac-filenames?: bool, delete?: bool, include?: string[], exclude?: string[]} $options */ - public function syncDown($sshUrl, $remoteDir, $localDir, array $options = []) + public function syncDown(string $sshUrl, string $remoteDir, string $localDir, array $options = []): void { $from = sprintf('%s:%s/', $sshUrl, $remoteDir); $to = $localDir; @@ -104,12 +89,9 @@ public function syncDown($sshUrl, $remoteDir, $localDir, array $options = []) /** * Runs rsync. * - * @param string $from - * @param string $to - * @param string $sshUrl - * @param array $options + * @param array{verbose?: bool, quiet?: bool, convert-mac-filenames?: bool, delete?: bool, include?: string[], exclude?: string[]} $options */ - private function doSync($from, $to, $sshUrl, array $options = []) + private function doSync(string $from, string $to, string $sshUrl, array $options = []): void { $params = ['rsync', '--archive', '--compress', '--human-readable']; @@ -146,6 +128,6 @@ private function doSync($from, $to, $sshUrl, array $options = []) } } - $this->shell->execute($params, null, true, false, $this->env($sshUrl), null); + $this->shell->mustExecute($params, quiet: false, env: $this->env($sshUrl), timeout: null); } } diff --git a/src/Service/SelfInstallChecker.php b/src/Service/SelfInstallChecker.php new file mode 100644 index 0000000000..8e329f348c --- /dev/null +++ b/src/Service/SelfInstallChecker.php @@ -0,0 +1,76 @@ +stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; + } + + public function checkSelfInstall(): void + { + // Avoid checking more than once in this process. + if (self::$checkedSelfInstall) { + return; + } + self::$checkedSelfInstall = true; + + // Avoid if disabled, or if in non-interactive mode. + if (!$this->config->getBool('application.prompt_self_install') + || !$this->config->isCommandEnabled('self:install') + || !$this->input->isInteractive()) { + return; + } + + // Avoid if already installed. + if (file_exists($this->config->getUserConfigDir() . DIRECTORY_SEPARATOR . SelfInstallCommand::INSTALLED_FILENAME)) { + return; + } + + // Avoid if other CLIs are installed. + if ($this->config->isWrapped() || $this->installationInfo->otherCLIsInstalled()) { + return; + } + + // Stop if already prompted and declined. + if ($this->state->get('self_install.last_prompted') !== false) { + return; + } + + $this->stdErr->writeln('CLI resource files can be installed automatically. They provide support for autocompletion and other features.'); + $questionText = 'Do you want to install these files?'; + if (file_exists($this->config->getUserConfigDir() . DIRECTORY_SEPARATOR . '/shell-config.rc')) { + $questionText = 'Do you want to complete the installation?'; + } + $answer = $this->questionHelper->confirm($questionText); + $this->state->set('self_install.last_prompted', time()); + $this->stdErr->writeln(''); + + if ($answer) { + $this->subCommandRunner->run('self:install'); + } else { + $this->stdErr->writeln('To install at another time, run: ' . $this->config->getStr('application.executable') . ' self:install'); + } + + $this->stdErr->writeln(''); + } +} diff --git a/src/Service/SelfUpdateChecker.php b/src/Service/SelfUpdateChecker.php new file mode 100644 index 0000000000..dbaede7e87 --- /dev/null +++ b/src/Service/SelfUpdateChecker.php @@ -0,0 +1,129 @@ +stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; + } + + public function checkUpdates(): void + { + // Avoid checking more than once in this process. + if (self::$checkedUpdates) { + return; + } + self::$checkedUpdates = true; + + // Check that the Phar extension is available. + if (!extension_loaded('Phar')) { + return; + } + + // Get the filename of the Phar, or stop if this instance of the CLI is + // not a Phar. + $pharFilename = \Phar::running(false); + if (!$pharFilename) { + return; + } + + // Check if the file and its containing directory are writable. + if (!is_writable($pharFilename) || !is_writable(dirname($pharFilename))) { + return; + } + + // Check if updates are configured. + if (!$this->config->get('updates.check')) { + return; + } + + // Determine an embargo time, after which updates can be checked. + $timestamp = time(); + $embargoTime = $timestamp - $this->config->getInt('updates.check_interval'); + + // Stop if updates were last checked after the embargo time. + if ($this->state->get('updates.last_checked') > $embargoTime) { + return; + } + + // Stop if the Phar was updated after the embargo time. + if (filemtime($pharFilename) > $embargoTime) { + return; + } + + // Ensure classes are auto-loaded if they may be needed after the + // update. + $currentVersion = $this->config->getVersion(); + + $this->selfUpdater->setAllowMajor(true); + $this->selfUpdater->setTimeout(5); + + try { + $newVersion = $this->selfUpdater->update(null, $currentVersion); + } catch (\RuntimeException $e) { + if (str_contains($e->getMessage(), 'Failed to download')) { + $this->stdErr->writeln('' . $e->getMessage() . ''); + $newVersion = false; + } else { + throw $e; + } + } + + $this->state->set('updates.last_checked', $timestamp); + + if ($newVersion === '') { + // No update was available. + return; + } + + if ($newVersion !== false) { + // Update succeeded. Continue (based on a few conditions). + $exitCode = 0; + [$currentMajorVersion, ] = explode('.', $currentVersion, 2); + [$newMajorVersion, ] = explode('.', $newVersion, 2); + if ($newMajorVersion === $currentMajorVersion + && $this->input instanceof ArgvInput + && is_executable($pharFilename)) { + $originalCommand = $this->input->__toString(); + if (empty($originalCommand)) { + $exitCode = $this->shell->executeSimple(escapeshellarg($pharFilename)); + exit($exitCode); + } + + $questionText = "\n" + . 'Original command: ' . $originalCommand . '' + . "\n\n" . 'Continue?'; + if ($this->questionHelper->confirm($questionText)) { + $this->stdErr->writeln(''); + $exitCode = $this->shell->executeSimple(escapeshellarg($pharFilename) . ' ' . $originalCommand); + } + } + exit($exitCode); + } + + // Automatic update failed. + // Error messages will already have been printed, and the original + // command can continue. + $this->stdErr->writeln(''); + } +} diff --git a/src/Service/SelfUpdater.php b/src/Service/SelfUpdater.php index fb85589f89..a27053cc19 100644 --- a/src/Service/SelfUpdater.php +++ b/src/Service/SelfUpdater.php @@ -1,4 +1,7 @@ input = $input; - $this->output = $output; - $this->stdErr = $output instanceof ConsoleOutputInterface - ? $output->getErrorOutput() - : $output; - $this->config = $cliConfig; - $this->questionHelper = $questionHelper; + $this->stdErr = $this->output instanceof ConsoleOutputInterface + ? $this->output->getErrorOutput() + : $this->output; } /** @@ -48,7 +43,7 @@ public function __construct( * @param int $timeout * The timeout in seconds. */ - public function setTimeout($timeout) + public function setTimeout(int $timeout): void { $this->timeout = $timeout; } @@ -58,7 +53,7 @@ public function setTimeout($timeout) * * @param bool $allowUnstable */ - public function setAllowUnstable($allowUnstable = true) + public function setAllowUnstable(bool $allowUnstable = true): void { $this->allowUnstable = $allowUnstable; } @@ -68,7 +63,7 @@ public function setAllowUnstable($allowUnstable = true) * * @param bool $allowMajor */ - public function setAllowMajor($allowMajor = true) + public function setAllowMajor(bool $allowMajor = true): void { $this->allowMajor = $allowMajor; } @@ -82,15 +77,15 @@ public function setAllowMajor($allowMajor = true) * @return false|string * The new version number, or an empty string if there was no update, or false on error. */ - public function update($manifestUrl = null, $currentVersion = null) + public function update(?string $manifestUrl = null, ?string $currentVersion = null): string|false { $currentVersion = $currentVersion ?: $this->config->getVersion(); - $manifestUrl = $manifestUrl ?: $this->config->get('application.manifest_url'); - $applicationName = $this->config->get('application.name'); + $manifestUrl = $manifestUrl ?: $this->config->getStr('application.manifest_url'); + $applicationName = $this->config->getStr('application.name'); if (!extension_loaded('Phar') || !($localPhar = \Phar::running(false))) { $this->stdErr->writeln(sprintf( 'This instance of the %s was not installed as a Phar archive.', - $applicationName + $applicationName, )); // Instructions for users who are running a global Composer install. @@ -98,8 +93,8 @@ public function update($manifestUrl = null, $currentVersion = null) $this->stdErr->writeln("Update using:\n\n composer global update"); if ($this->config->has('application.package_name')) { $this->stdErr->writeln("\nOr you can switch to a Phar install (recommended):\n"); - $this->stdErr->writeln(" composer global remove " . $this->config->get('application.package_name')); - $this->stdErr->writeln(" curl -sS " . $this->config->get('application.installer_url') . " | php\n"); + $this->stdErr->writeln(" composer global remove " . $this->config->getStr('application.package_name')); + $this->stdErr->writeln(" curl -sS " . $this->config->getStr('application.installer_url') . " | php\n"); } else { $this->stdErr->writeln("\nOr you can switch to a Phar install (recommended)\n"); } @@ -111,7 +106,7 @@ public function update($manifestUrl = null, $currentVersion = null) $this->stdErr->writeln(sprintf( 'Checking for %s updates (current version: %s)', $applicationName, - $currentVersion + $currentVersion, )); if (!is_writable($localPhar)) { @@ -140,7 +135,7 @@ public function update($manifestUrl = null, $currentVersion = null) // Some dev versions cannot be compared against other version numbers, // so do not check for release notes in that case. - $currentIsDev = \strpos($currentVersion, 'dev-') === 0; + $currentIsDev = str_starts_with($currentVersion, 'dev-'); if (!$currentIsDev && ($notes = $strategy->getUpdateNotesByVersion($currentVersion, $newVersionString))) { $this->stdErr->writeln(''); @@ -165,7 +160,7 @@ public function update($manifestUrl = null, $currentVersion = null) $this->stdErr->writeln(sprintf( 'The %s has been successfully updated to version %s', $applicationName, - $newVersionString + $newVersionString, )); return $newVersionString; diff --git a/src/Service/Shell.php b/src/Service/Shell.php index 880f478627..9cc19da1dd 100644 --- a/src/Service/Shell.php +++ b/src/Service/Shell.php @@ -1,7 +1,10 @@ # '; - private $debugPrefix = '# '; + private static ?string $phpVersion = null; - /** @var string|null */ - private static $phpVersion; - - public function __construct(OutputInterface $output = null) + public function __construct(?OutputInterface $output = null) { $this->setOutput($output ?: new NullOutput()); } @@ -34,7 +32,7 @@ public function __construct(OutputInterface $output = null) * * @param OutputInterface $output */ - public function setOutput(OutputInterface $output) + public function setOutput(OutputInterface $output): void { $this->output = $output; $this->stdErr = $output instanceof ConsoleOutputInterface @@ -43,25 +41,23 @@ public function setOutput(OutputInterface $output) } /** - * Execute a command, using STDIN, STDERR and STDOUT directly. + * Executes a command, using STDIN, STDERR and STDOUT directly. * - * @param string $commandline - * @param string|null $dir - * @param array $env + * @param array $env Extra environment variables. * * @return int * The command's exit code (0 on success, a different integer on failure). */ - public function executeSimple($commandline, $dir = null, array $env = []) + public function executeSimple(string $commandline, ?string $dir = null, array $env = []): int { $this->stdErr->writeln( - sprintf( '%sRunning command: %s', $this->debugPrefix, $commandline), - OutputInterface::VERBOSITY_VERY_VERBOSE + sprintf('%sRunning command: %s', $this->debugPrefix, $commandline), + OutputInterface::VERBOSITY_VERY_VERBOSE, ); if (!empty($env)) { $this->showEnvMessage($env); - $env = $env + $this->getParentEnv(); + $env = $env + getenv(); } else { $env = null; } @@ -69,6 +65,9 @@ public function executeSimple($commandline, $dir = null, array $env = []) $this->showWorkingDirMessage($dir); $process = proc_open($commandline, [STDIN, STDOUT, STDERR], $pipes, $dir, $env); + if (!$process) { + throw new \RuntimeException('Failed to start process for command: ' . $commandline); + } return proc_close($process); } @@ -76,43 +75,39 @@ public function executeSimple($commandline, $dir = null, array $env = []) /** * Executes a command. * - * @param string|array $args - * @param string|null $dir - * @param bool $mustRun - * @param bool $quiet - * @param array $env - * @param int|null $timeout - * @param string|null $input + * @param string[]|string $args + * @param string|null $dir + * @param bool $mustRun + * @param bool $quiet + * @param array $env + * @param int|null $timeout + * @param string|null $input * - * @throws \Symfony\Component\Process\Exception\RuntimeException - * If $mustRun is enabled and the command fails. + * @return false|string + * False if the command fails and $mustRun is false, or the command + * output if it succeeds, with trailing whitespace removed. * - * @return bool|string - * False if the command fails, true if it succeeds with no output, or a - * string if it succeeds with output. + * @throws RuntimeException + * If $mustRun is enabled and the command fails. */ - public function execute($args, $dir = null, $mustRun = false, $quiet = true, array $env = [], $timeout = 3600, $input = null) + public function execute(array|string $args, ?string $dir = null, bool $mustRun = false, bool $quiet = true, array $env = [], ?int $timeout = 3600, mixed $input = null): false|string { $process = $this->setupProcess($args, $dir, $env, $timeout, $input); - $result = $this->runProcess($process, $mustRun, $quiet); + $exitCode = $this->runProcess($process, $mustRun, $quiet); + if ($exitCode > 0) { + return false; + } - return is_int($result) ? $result === 0 : $result; + return rtrim($process->getOutput()); } /** * Executes a command and returns the process object. * - * @param string|array $args - * @param string|null $dir - * @param bool $mustRun - * @param bool $quiet - * @param array $env - * @param int|null $timeout - * @param string|null $input - * - * @return Process + * @param string|string[] $args + * @param array $env */ - public function executeCaptureProcess($args, $dir = null, $mustRun = false, $quiet = true, array $env = [], $timeout = 3600, $input = null) + public function executeCaptureProcess(string|array $args, ?string $dir = null, bool $mustRun = false, bool $quiet = true, array $env = [], ?int $timeout = 3600, mixed $input = null): Process { $process = $this->setupProcess($args, $dir, $env, $timeout, $input); $this->runProcess($process, $mustRun, $quiet); @@ -120,33 +115,39 @@ public function executeCaptureProcess($args, $dir = null, $mustRun = false, $qui } /** - * Sets up a Process and reports to the user that the command is being run. + * Executes a command and returns its output, throwing an exception on failure. + * + * @param string|string[] $args + * @param array $env * - * @param $args - * @param $dir - * @param array $env - * @param $timeout - * @param $input - * @return Process + * @return string The command output, with trailing whitespace trimmed. */ - private function setupProcess($args, $dir = null, array $env = [], $timeout = 3600, $input = null) + public function mustExecute(string|array $args, ?string $dir = null, bool $quiet = true, array $env = [], ?int $timeout = 3600, mixed $input = null): string { - $process = new Process($args, null, null, $input, $timeout); + return (string) $this->execute($args, $dir, true, $quiet, $env, $timeout, $input); + } - // Avoid adding 'exec' to every command. It is not needed in this - // context as we do not need to send signals to the process. Also it - // causes compatibility issues, at least with the shell built-in command - // 'command' on Travis containers. - // See https://github.com/symfony/symfony/issues/23495 - $process->setCommandLine($process->getCommandLine()); + /** + * Sets up a Process and reports to the user that the command is being run. + * + * @param string[]|string $args + * @param array $env + */ + private function setupProcess(string|array $args, ?string $dir = null, array $env = [], int|null $timeout = 3600, mixed $input = null): Process + { + if (is_string($args)) { + $process = Process::fromShellCommandline($args, null, null, $input, $timeout); + } else { + $process = new Process($args, null, null, $input, $timeout); + } if ($timeout === null) { set_time_limit(0); } $this->stdErr->writeln( - sprintf( '%sRunning command: %s', $this->debugPrefix, $process->getCommandLine()), - OutputInterface::VERBOSITY_VERY_VERBOSE + sprintf('%sRunning command: %s', $this->debugPrefix, $process->getCommandLine()), + OutputInterface::VERBOSITY_VERY_VERBOSE, ); if (!empty($input) && is_string($input) && $this->stdErr->isDebug()) { @@ -155,7 +156,7 @@ private function setupProcess($args, $dir = null, array $env = [], $timeout = 36 if (!empty($env)) { $this->showEnvMessage($env); - $process->setEnv($env + $this->getParentEnv()); + $process->setEnv($env + getenv()); } if ($dir && is_dir($dir)) { @@ -166,10 +167,7 @@ private function setupProcess($args, $dir = null, array $env = [], $timeout = 36 return $process; } - /** - * @param string|null $dir - */ - private function showWorkingDirMessage($dir) + private function showWorkingDirMessage(?string $dir): void { if ($dir !== null && $this->stdErr->isDebug()) { $this->stdErr->writeln($this->debugPrefix . ' Working directory: ' . $dir); @@ -177,9 +175,9 @@ private function showWorkingDirMessage($dir) } /** - * @param array $env + * @param array $env */ - private function showEnvMessage(array $env) + private function showEnvMessage(array $env): void { if (!empty($env) && $this->stdErr->isDebug()) { $message = [$this->debugPrefix . ' Using additional environment variables:']; @@ -190,47 +188,6 @@ private function showEnvMessage(array $env) } } - /** - * Attempt to read useful environment variables from the parent process. - * - * @return array - */ - protected function getParentEnv() - { - if (PHP_VERSION_ID >= 70100) { - return getenv(); - } - // In PHP <7.1 there isn't a way to read all of the current environment - // variables. If PHP is running with a variables_order that includes - // 'e', then $_ENV should be populated. - if (!empty($_ENV) && stripos(ini_get('variables_order'), 'e') !== false) { - return $_ENV; - } - - // If $_ENV is empty, then guess all the variables that we might want to use. - $candidates = [ - 'TERM', - 'TERM_SESSION_ID', - 'TMPDIR', - 'SSH_AGENT_PID', - 'SSH_AUTH_SOCK', - 'PATH', - 'LANG', - 'LC_ALL', - 'LC_CTYPE', - 'PAGER', - 'LESS', - ]; - $variables = []; - foreach ($candidates as $name) { - $variables[$name] = getenv($name); - } - - return array_filter($variables, function ($value) { - return $value !== false; - }); - } - /** * Run a process. * @@ -238,17 +195,16 @@ protected function getParentEnv() * @param bool $mustRun * @param bool $quiet * - * @throws \Symfony\Component\Process\Exception\RuntimeException + * @throws RuntimeException * If the process fails or times out, and $mustRun is true. * - * @return int|bool|string - * The exit code of the process if it fails, true if it succeeds with no - * output, or a string if it succeeds with output. + * @return int + * The exit code of the process. */ - protected function runProcess(Process $process, $mustRun = false, $quiet = true) + private function runProcess(Process $process, bool $mustRun = false, bool $quiet = true): int { try { - $process->mustRun(function ($type, $buffer) use ($quiet) { + $process->mustRun(function ($type, $buffer) use ($quiet): void { $output = $type === Process::ERR ? $this->stdErr : $this->output; // Show the output if $quiet is false, and always show stderr // output in debug mode. @@ -257,7 +213,7 @@ protected function runProcess(Process $process, $mustRun = false, $quiet = true) $output->write(preg_replace('/(^|[\n\r]+)(.)/', '$1 $2', $buffer)); } }); - } catch (ProcessFailedException $e) { + } catch (ProcessFailedException) { if (!$mustRun) { return $process->getExitCode(); } @@ -268,9 +224,7 @@ protected function runProcess(Process $process, $mustRun = false, $quiet = true) // will generate a much shorter message. throw new \Platformsh\Cli\Exception\ProcessFailedException($process, $quiet); } - $output = $process->getOutput(); - - return $output ? rtrim($output) : true; + return 0; } /** @@ -279,10 +233,10 @@ protected function runProcess(Process $process, $mustRun = false, $quiet = true) * @param string $command * @param bool $noticeOnError * - * @return string|bool + * @return string|false * A list of command paths (one per line) or false on failure. */ - protected function findWhere($command, $noticeOnError = true) + protected function findWhere(string $command, bool $noticeOnError = true): string|false { static $result; if (!isset($result[$command])) { @@ -296,7 +250,7 @@ protected function findWhere($command, $noticeOnError = true) } foreach ($commands as $args) { try { - $result[$command] = $this->execute($args); + $result[$command] = $this->mustExecute($args); } catch (ProcessFailedException $e) { $result[$command] = false; if ($this->exceptionMeansCommandDoesNotExist($e)) { @@ -321,7 +275,8 @@ protected function findWhere($command, $noticeOnError = true) * * @return bool */ - public function exceptionMeansCommandDoesNotExist(ProcessFailedException $e) { + public function exceptionMeansCommandDoesNotExist(ProcessFailedException $e): bool + { $process = $e->getProcess(); if ($process->getExitCode() === 127) { return true; @@ -344,23 +299,19 @@ public function exceptionMeansCommandDoesNotExist(ProcessFailedException $e) { * * @return bool */ - public function commandExists($command) + public function commandExists(string $command): bool { return (bool) $this->findWhere($command, false); } /** - * Find the absolute path to an executable. - * - * @param string $command - * - * @return string + * Finds the absolute path to an executable. */ - public function resolveCommand($command) + public function resolveCommand(string $command): string { if ($fullPaths = $this->findWhere($command)) { - $fullPaths = preg_split('/[\r\n]/', trim($fullPaths)); - $command = end($fullPaths); + $fullPaths = (array) preg_split('/[\r\n]/', trim($fullPaths)); + $command = (string) end($fullPaths); } return $command; @@ -371,18 +322,15 @@ public function resolveCommand($command) * * Falls back to the version of PHP running the CLI (which may or may not * be the same). - * - * @return string */ - public function getPhpVersion() + public function getPhpVersion(): string { if (!isset(self::$phpVersion)) { - $result = $this->execute([ + self::$phpVersion = $this->execute([ (new PhpExecutableFinder())->find() ?: PHP_BINARY, '-r', 'echo PHP_VERSION;', - ]); - self::$phpVersion = is_string($result) ? $result : PHP_VERSION; + ]) ?: PHP_VERSION; } return self::$phpVersion; } diff --git a/src/Service/Ssh.php b/src/Service/Ssh.php index 0caa733a10..6b0c4d88f1 100644 --- a/src/Service/Ssh.php +++ b/src/Service/Ssh.php @@ -1,5 +1,7 @@ input = $input; - $this->output = $output; - $this->config = $config; - $this->sshKey = $sshKey; - $this->certifier = $certifier; - $this->sshConfig = $sshConfig; $this->stdErr = $this->output instanceof ConsoleOutputInterface ? $this->output->getErrorOutput() : $this->output; } /** - * @param \Symfony\Component\Console\Input\InputDefinition $definition + * @param InputDefinition $definition */ - public static function configureInput(InputDefinition $definition) + public static function configureInput(InputDefinition $definition): void { $definition->addOption( - new HiddenInputOption('identity-file', 'i', InputOption::VALUE_REQUIRED, 'Deprecated: an SSH identity (private key) to use. The auto-generated certificate is recommended instead.') + new HiddenInputOption('identity-file', 'i', InputOption::VALUE_REQUIRED, 'Deprecated: an SSH identity (private key) to use. The auto-generated certificate is recommended instead.'), ); } @@ -57,9 +46,9 @@ public static function configureInput(InputDefinition $definition) * @param string[]|string|null $remoteCommand * A command to run on the remote host. * - * @return array + * @return string[] */ - public function getSshArgs($uri, array $extraOptions = [], $remoteCommand = null) + public function getSshArgs(string $uri, array $extraOptions = [], array|string|null $remoteCommand = null): array { $options = array_merge($this->getSshOptions($this->hostIsInternal($uri)), $extraOptions); @@ -78,7 +67,7 @@ public function getSshArgs($uri, array $extraOptions = [], $remoteCommand = null if (count($remoteCommand) === 1) { $args[] = reset($remoteCommand); } else { - $args[] = implode(' ', array_map([OsUtil::class, 'escapePosixShellArg'], $remoteCommand)); + $args[] = implode(' ', array_map(OsUtil::escapePosixShellArg(...), $remoteCommand)); } } else { $args[] = $remoteCommand; @@ -95,14 +84,14 @@ public function getSshArgs($uri, array $extraOptions = [], $remoteCommand = null * * @return string[] An array of SSH options. */ - private function getSshOptions($hostIsInternal) + private function getSshOptions(?bool $hostIsInternal): array { $options = []; $options[] = 'SendEnv TERM'; if ($this->output->isDebug()) { - if ($this->config->get('api.debug')) { + if ($this->config->getBool('api.debug')) { $options[] = 'LogLevel DEBUG3'; } else { $options[] = 'LogLevel DEBUG'; @@ -169,7 +158,7 @@ private function getSshOptions($hostIsInternal) } if ($configuredOptions = $this->config->get('ssh.options')) { - $options = array_merge($options, is_array($configuredOptions) ? $configuredOptions : explode("\n", $configuredOptions)); + $options = array_merge($options, is_array($configuredOptions) ? $configuredOptions : explode("\n", (string) $configuredOptions)); } // Avoid repeating options. @@ -195,14 +184,14 @@ private function getSshOptions($hostIsInternal) * * @return string */ - public function getSshCommand($url, array $extraOptions = [], $remoteCommand = null, $omitUrl = false, $autoConfigure = true) + public function getSshCommand(string $url, array $extraOptions = [], array|string|null $remoteCommand = null, bool $omitUrl = false, bool $autoConfigure = true): string { $command = 'ssh'; if (!$omitUrl) { $command .= ' ' . OsUtil::escapeShellArg($url); } if ($args = $this->getSshArgs($url, $extraOptions, $remoteCommand)) { - $command .= ' ' . implode(' ', array_map([OsUtil::class, 'escapeShellArg'], $args)); + $command .= ' ' . implode(' ', array_map(OsUtil::escapeShellArg(...), $args)); } // Configure or validate the session SSH config. @@ -223,7 +212,7 @@ public function getSshCommand($url, array $extraOptions = [], $remoteCommand = n * * @return array */ - public function getEnv() + public function getEnv(): array { // Suppress refreshing the certificate while SSH is running through the CLI. return [self::SSH_NO_REFRESH_ENV_VAR => '1']; @@ -234,18 +223,18 @@ public function getEnv() * * @param string $uri * - * @return string|false|null + * @return string|false */ - private function getHost($uri) + private function getHost(string $uri): string|false { - if (\strpos($uri, '@') !== false) { - list(, $uri) = \explode('@', $uri, 2); + if (str_contains($uri, '@')) { + [, $uri] = \explode('@', $uri, 2); } - if (\strpos($uri, '://') !== false) { - list(, $uri) = \explode('://', $uri, 2); + if (str_contains($uri, '://')) { + [, $uri] = \explode('://', $uri, 2); } - if (\strpos($uri, ':') !== false) { - list($uri, ) = \explode(':', $uri, 2); + if (str_contains($uri, ':')) { + [$uri, ] = \explode(':', $uri, 2); } if (!preg_match('@^[\p{Ll}0-9-]+\.[\p{Ll}0-9-]+@', $uri)) { return false; @@ -261,7 +250,7 @@ private function getHost($uri) * @return bool|null * True if the URI is for an internal server, false if it's external, or null if it cannot be determined. */ - public function hostIsInternal($uri) + public function hostIsInternal(string $uri): ?bool { $host = $this->getHost($uri); if (!$host) { @@ -273,7 +262,7 @@ public function hostIsInternal($uri) return null; } foreach ($wildcards as $wildcard) { - if (\strpos($host, \str_replace('*.', '', $wildcard)) !== false) { + if (str_contains($host, \str_replace('*.', '', $wildcard))) { return true; } } diff --git a/src/Service/SshConfig.php b/src/Service/SshConfig.php index 04445cc8b0..819074a474 100644 --- a/src/Service/SshConfig.php +++ b/src/Service/SshConfig.php @@ -1,5 +1,7 @@ config = $config; - $this->fs = $fs; $this->stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; - $this->sshKey = $sshKey; - $this->certifier = $certifier; } /** @@ -33,10 +28,10 @@ public function __construct(Config $config, Filesystem $fs, OutputInterface $out * @return string|null * The path to the file containing the host keys, if any. */ - public function configureHostKeys() + public function configureHostKeys(): ?string { $hostKeys = ''; - if ($hostKeysFile = $this->config->getWithDefault('ssh.host_keys_file', '')) { + if ($hostKeysFile = $this->config->getStr('ssh.host_keys_file')) { $hostKeysFile = CLI_ROOT . DIRECTORY_SEPARATOR . $hostKeysFile; $hostKeys = file_get_contents($hostKeysFile); if ($hostKeys === false) { @@ -46,9 +41,9 @@ public function configureHostKeys() } if ($additionalKeys = $this->config->getWithDefault('ssh.host_keys', [])) { if (!is_array($additionalKeys)) { - $additionalKeys = explode("\n", $additionalKeys); + $additionalKeys = explode("\n", (string) $additionalKeys); } - $hostKeys = rtrim($hostKeys, "\n") . "\n" . $additionalKeys; + $hostKeys = rtrim($hostKeys, "\n") . "\n" . implode("\n", $additionalKeys); } if (empty($hostKeys)) { return null; @@ -74,7 +69,7 @@ public function configureHostKeys() * @return bool * True if there is any session configuration, false otherwise. */ - public function configureSessionSsh() + public function configureSessionSsh(): bool { if (!$this->supportsInclude()) { return false; @@ -95,7 +90,7 @@ public function configureSessionSsh() $certificate = $this->certifier->getExistingCertificate(); if ($certificate) { - $executable = $this->config->get('application.executable'); + $executable = $this->config->getStr('application.executable'); $refreshCommand = sprintf('%s ssh-cert:load --refresh-only --yes --quiet', $executable); // On shells where it might work (POSIX compliant), skip refreshing @@ -103,7 +98,7 @@ public function configureSessionSsh() // This skips unnecessary CLI bootstrapping while running SSH from // the CLI itself. /** @see Ssh::getEnv() */ - if (in_array(basename(getenv('SHELL')), ['bash', 'csh', 'dash', 'ksh', 'tcsh', 'zsh'], true)) { + if (in_array(basename((string) getenv('SHELL')), ['bash', 'csh', 'dash', 'ksh', 'tcsh', 'zsh'], true)) { // Literal double-quotes do not appear to be possible in SSH config. // See: https://bugzilla.mindrot.org/show_bug.cgi?id=3474 // So the condition here uses single quotes after the variable @@ -149,15 +144,10 @@ public function configureSessionSsh() $sessionSpecificFilename = $this->getSessionSshDir() . DIRECTORY_SEPARATOR . 'config'; $includerFilename = $this->getCliSshDir() . DIRECTORY_SEPARATOR . 'session.config'; - if (empty($lines)) { - if (\file_exists($includerFilename) || \file_exists($sessionSpecificFilename)) { - $this->fs->remove([$includerFilename, $sessionSpecificFilename]); - } - return false; - } // Add other configured options. if ($configuredOptions = $this->config->get('ssh.options')) { + /** @var string[]|string $configuredOptions */ $lines[] = ''; $lines[] = "# Other options from the CLI's configuration."; $lines = array_merge($lines, is_array($configuredOptions) ? $configuredOptions : explode("\n", $configuredOptions)); @@ -177,7 +167,7 @@ public function configureSessionSsh() } $this->writeSshIncludeFile( $includerFilename, - $includerLines + $includerLines, ); return true; @@ -193,7 +183,7 @@ public function configureSessionSsh() * * @return string[] */ - public function formattedPaths($path) + public function formattedPaths(string $path): array { // Convert absolute Windows paths (e.g. beginning "C:\") to Unix paths. // OpenSSH apparently treats the Windows format as a relative path. @@ -231,15 +221,11 @@ public function formattedPaths($path) * the HOME environment variable is set to a directory other than the * current user's (notably on GitHub Actions containers), because the * OpenSSH client does not support reading HOME. - * - * @param string $path - * - * @return string */ - private function quoteFilePath($path) + private function quoteFilePath(string $path): string { // Quote all paths containing a space. - if (\strpos($path, ' ') !== false) { + if (str_contains($path, ' ')) { // The three quote marks in the middle mean: end quote, literal quote mark, start quote. return '"' . \str_replace('"', '"""', $path) . '"'; } @@ -250,11 +236,9 @@ private function quoteFilePath($path) /** * Creates or updates an SSH config include file. * - * @param string $filename - * @param array|string $lines - * @param bool $allowDelete + * @param string[]|string $lines */ - private function writeSshIncludeFile($filename, $lines, $allowDelete = true) + private function writeSshIncludeFile(string $filename, array|string $lines, bool $allowDelete = true): void { if (empty($lines) && $allowDelete && \file_exists($filename)) { $this->stdErr->writeln(sprintf('Deleting SSH configuration include file: %s', $filename), OutputInterface::VERBOSITY_VERBOSE); @@ -271,7 +255,7 @@ private function writeSshIncludeFile($filename, $lines, $allowDelete = true) } else { $this->stdErr->writeln(sprintf('Validated SSH configuration include file: %s', $filename), OutputInterface::VERBOSITY_VERY_VERBOSE); } - $this->chmod($filename, 0600); + $this->chmod($filename, 0o600); } /** @@ -279,7 +263,7 @@ private function writeSshIncludeFile($filename, $lines, $allowDelete = true) * * @return string */ - public function getSessionSshDir() + public function getSessionSshDir(): string { return $this->config->getSessionDir(true) . DIRECTORY_SEPARATOR . 'ssh'; } @@ -291,7 +275,7 @@ public function getSessionSshDir() * * @return bool */ - public function addUserSshConfig(QuestionHelper $questionHelper) + public function addUserSshConfig(QuestionHelper $questionHelper): bool { if (!$this->supportsInclude()) { return false; @@ -328,7 +312,7 @@ public function addUserSshConfig(QuestionHelper $questionHelper) $this->stdErr->writeln('Failed to read file: ' . $filename . ''); return false; } - if (strpos($currentContents, $suggestedConfig) !== false) { + if (str_contains($currentContents, $suggestedConfig)) { $this->stdErr->writeln('Validated SSH configuration file: ' . $filename . '', OutputInterface::VERBOSITY_VERBOSE); return true; } @@ -359,7 +343,7 @@ public function addUserSshConfig(QuestionHelper $questionHelper) $this->stdErr->writeln('Configuration file updated successfully: ' . $filename . ''); } - $this->chmod($filename, 0600); + $this->chmod($filename, 0o600); return true; } @@ -370,7 +354,7 @@ public function addUserSshConfig(QuestionHelper $questionHelper) * @return bool|null * True for yes, false for no, null to prompt the user. */ - private function shouldWriteUserSshConfig() + private function shouldWriteUserSshConfig(): ?bool { $value = $this->config->has('api.write_user_ssh_config') ? $this->config->get('api.write_user_ssh_config') @@ -389,7 +373,7 @@ private function shouldWriteUserSshConfig() * * Called from the logout command. */ - public function deleteSessionConfiguration() + public function deleteSessionConfiguration(): void { $files = [ $this->getSessionSshDir() . DIRECTORY_SEPARATOR . 'config', @@ -410,7 +394,7 @@ public function deleteSessionConfiguration() * * @return bool */ - private function chmod($filename, $mode) + private function chmod(string $filename, int $mode): bool { if (!@chmod($filename, $mode)) { $this->stdErr->writeln('Warning: failed to change permissions on file: ' . $filename . ''); @@ -424,7 +408,7 @@ private function chmod($filename, $mode) * * @return string */ - private function getCliSshDir() + private function getCliSshDir(): string { return $this->config->getWritableUserDir() . DIRECTORY_SEPARATOR . 'ssh'; } @@ -440,9 +424,9 @@ private function getCliSshDir() * * @return string The new file contents. */ - private function getUserSshConfigChanges($currentConfig, $newConfig) + private function getUserSshConfigChanges(string $currentConfig, string $newConfig): string { - $serviceName = (string)$this->config->get('service.name'); + $serviceName = $this->config->getStr('service.name'); $begin = '# BEGIN: ' . $serviceName . ' certificate configuration' . PHP_EOL; $end = PHP_EOL . '# END: ' . $serviceName . ' certificate configuration'; @@ -454,7 +438,7 @@ private function getUserSshConfigChanges($currentConfig, $newConfig) * * @return string */ - private function getUserSshConfigFilename() + private function getUserSshConfigFilename(): string { return $this->config->getHomeDirectory() . DIRECTORY_SEPARATOR . '.ssh' . DIRECTORY_SEPARATOR . 'config'; } @@ -464,7 +448,7 @@ private function getUserSshConfigFilename() * * @todo use this? maybe in an uninstall command */ - public function removeUserSshConfig() + public function removeUserSshConfig(): void { $sshConfigFile = $this->getUserSshConfigFilename(); if (!file_exists($sshConfigFile)) { @@ -495,13 +479,13 @@ public function removeUserSshConfig() * * @return string|false */ - private function findVersion($reset = false) + private function findVersion(bool $reset = false): string|false { if (isset($this->openSshVersion) && !$reset) { return $this->openSshVersion; } $this->openSshVersion = false; - $process = new Process('ssh -V'); + $process = new Process(['ssh', '-V']); $process->run(); $errorOutput = $process->getErrorOutput(); if (!$process->isSuccessful()) { @@ -525,7 +509,7 @@ private function findVersion($reset = false) * @return bool * True if the version is determined and it is lower than the $test value, false otherwise. */ - private function versionIsBelow($test) + private function versionIsBelow(string $test): bool { $version = $this->findVersion(); if (!$version) { @@ -539,7 +523,8 @@ private function versionIsBelow($test) * * @return bool */ - public function supportsInclude() { + public function supportsInclude(): bool + { return !$this->versionIsBelow('7.3'); } @@ -548,7 +533,8 @@ public function supportsInclude() { * * @return bool */ - public function supportsCertificateFile() { + public function supportsCertificateFile(): bool + { return !$this->versionIsBelow('7.2'); } @@ -558,7 +544,7 @@ public function supportsCertificateFile() { * @return bool * False if an incorrect version is installed, true otherwise. */ - public function checkRequiredVersion() + public function checkRequiredVersion(): bool { $version = $this->findVersion(); if (!$version) { @@ -567,14 +553,14 @@ public function checkRequiredVersion() if (\version_compare($version, '6.5', '<')) { $this->stdErr->writeln(\sprintf( 'OpenSSH version %s is installed. Version 6.5 or above is required. Some features depend on version 7.3 or above.', - $version + $version, )); return false; } if (\version_compare($version, '7.3', '<')) { $this->stdErr->writeln(\sprintf( 'OpenSSH version %s is installed. Some features depend on version 7.3 or above.', - $version + $version, )); } return true; diff --git a/src/Service/SshDiagnostics.php b/src/Service/SshDiagnostics.php index 1c96320431..e66bb42944 100644 --- a/src/Service/SshDiagnostics.php +++ b/src/Service/SshDiagnostics.php @@ -1,5 +1,7 @@ ssh = $ssh; $this->stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; - $this->sshKey = $sshKey; - $this->certifier = $certifier; - $this->api = $api; - $this->config = $config; } /** @@ -37,7 +28,7 @@ public function __construct(Ssh $ssh, OutputInterface $output, Certifier $certif * * @return string */ - private function noServiceAccessMessage(Process $failedProcess) + private function noServiceAccessMessage(Process $failedProcess): string { $errorOutput = $failedProcess->getErrorOutput(); if (stripos($errorOutput, "you successfully authenticated") !== false) { @@ -55,10 +46,10 @@ private function noServiceAccessMessage(Process $failedProcess) * * @return array{amr?: string[], max_age?: int} */ - private function stepUpAuthenticationParams(Process $failedProcess) + private function stepUpAuthenticationParams(Process $failedProcess): array { $errorOutput = $failedProcess->getErrorOutput(); - if (strpos($errorOutput, 'Error: Access denied') === false) { + if (!str_contains($errorOutput, 'Error: Access denied')) { return []; } if (preg_match('/^Parameters: ({.+)$/m', $errorOutput, $matches)) { @@ -75,7 +66,7 @@ private function stepUpAuthenticationParams(Process $failedProcess) * * @return bool */ - private function connectionFailedDueToMFA(Process $failedProcess) + private function connectionFailedDueToMFA(Process $failedProcess): bool { return stripos($failedProcess->getErrorOutput(), 'reason: access requires MFA') !== false; } @@ -87,7 +78,7 @@ private function connectionFailedDueToMFA(Process $failedProcess) * * @return bool */ - private function hostKeyVerificationFailed(Process $failedProcess) + private function hostKeyVerificationFailed(Process $failedProcess): bool { $stdErr = $failedProcess->getErrorOutput(); @@ -101,7 +92,7 @@ private function hostKeyVerificationFailed(Process $failedProcess) * * @return bool */ - private function keyAuthenticationFailed(Process $failedProcess) + private function keyAuthenticationFailed(Process $failedProcess): bool { return stripos($failedProcess->getErrorOutput(), "Permission denied (publickey)") !== false; } @@ -115,10 +106,10 @@ private function keyAuthenticationFailed(Process $failedProcess) * @return Process * A process (already run) that tested the SSH connection. */ - private function testConnection($uri, $timeout = 5) + private function testConnection(string $uri, float|int $timeout = 5): Process { $this->stdErr->writeln('Making test connection to diagnose SSH errors', OutputInterface::VERBOSITY_DEBUG); - $process = new Process($this->ssh->getSshCommand($uri, [], 'exit', false, false), null, $this->ssh->getEnv()); + $process = Process::fromShellCommandline($this->ssh->getSshCommand($uri, [], 'exit', false, false), null, $this->ssh->getEnv()); $process->setTimeout($timeout); $process->run(); $this->stdErr->writeln('Test connection complete', OutputInterface::VERBOSITY_DEBUG); @@ -132,12 +123,12 @@ private function testConnection($uri, $timeout = 5) * @param string[] $currentMethods * @return bool */ - private function authMethodsMatch(array $challengeMethods, array $currentMethods) + private function authMethodsMatch(array $challengeMethods, array $currentMethods): bool { $unmatched = array_diff($challengeMethods, $currentMethods); - if (in_array('sso:*', $currentMethods, TRUE)) { + if (in_array('sso:*', $currentMethods, true)) { foreach ($unmatched as $key => $method) { - if (strpos($method, 'sso:') === 0) { + if (str_starts_with($method, 'sso:')) { unset($unmatched[$key]); } } @@ -155,7 +146,7 @@ private function authMethodsMatch(array $challengeMethods, array $currentMethods * @param bool $newline * Whether to add a new line before messages. */ - public function diagnoseFailure($uri, Process $failedProcess, $newline = true) + public function diagnoseFailure(string $uri, Process $failedProcess, bool $newline = true): void { if (!$this->ssh->hostIsInternal($uri)) { return; @@ -171,7 +162,7 @@ public function diagnoseFailure($uri, Process $failedProcess, $newline = true) return; } - $executable = $this->config->get('application.executable'); + $executable = $this->config->getStr('application.executable'); if ($params = $this->stepUpAuthenticationParams($failedProcess)) { if ($newline) { @@ -192,7 +183,7 @@ public function diagnoseFailure($uri, Process $failedProcess, $newline = true) } } - $loginRequiredEvent = new LoginRequiredEvent(isset($params['amr']) ? $params['amr'] : [], isset($params['max_age']) ? $params['max_age'] : null, $this->api->hasApiToken()); + $loginRequiredEvent = new LoginRequiredEvent($params['amr'] ?? [], $params['max_age'] ?? null, $this->api->hasApiToken()); $this->stdErr->writeln($loginRequiredEvent->getExtendedMessage($this->config)); return; } @@ -226,7 +217,7 @@ public function diagnoseFailure($uri, Process $failedProcess, $newline = true) $this->stdErr->writeln('MFA is not yet enabled on your account.'); if ($this->config->has('api.mfa_setup_url')) { - $this->stdErr->writeln(\sprintf('Set up MFA at: %s', $this->config->get('api.mfa_setup_url'))); + $this->stdErr->writeln(\sprintf('Set up MFA at: %s', $this->config->getStr('api.mfa_setup_url'))); $this->stdErr->writeln(\sprintf('Then log in again with: %s login -f', $executable)); return; } @@ -260,14 +251,14 @@ public function diagnoseFailure($uri, Process $failedProcess, $newline = true) if (!$this->certifier->isAutoLoadEnabled() && !$this->certifier->getExistingCertificate() && $this->config->isCommandEnabled('ssh-cert:load')) { $this->stdErr->writeln(\sprintf( 'You may need to create an SSH certificate, by running: %s ssh-cert:load', - $executable + $executable, )); return; } if ($this->config->isCommandEnabled('ssh-key:add') && !$this->certifier->isAutoLoadEnabled() && !$this->certifier->getExistingCertificate()) { $this->stdErr->writeln(\sprintf( 'You may need to add an SSH key, by running: %s ssh-key:add', - $executable + $executable, )); } } @@ -285,7 +276,7 @@ public function diagnoseFailure($uri, Process $failedProcess, $newline = true) * @param int $exitCode * The exit code of the SSH command. Used to check if diagnostics are relevant. */ - public function diagnoseFailureWithTest($uri, $startTime, $exitCode) + public function diagnoseFailureWithTest(string $uri, int $startTime, int $exitCode): void { if ($exitCode !== self::_SSH_ERROR_EXIT_CODE || !$this->ssh->hostIsInternal($uri)) { return; diff --git a/src/Service/SshKey.php b/src/Service/SshKey.php index 9be8397a62..6aa7472891 100644 --- a/src/Service/SshKey.php +++ b/src/Service/SshKey.php @@ -1,5 +1,7 @@ config = $config; - $this->api = $api; +readonly class SshKey +{ + private OutputInterface $stdErr; + + public function __construct(private Config $config, private Api $api, OutputInterface $output) + { $this->stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; } @@ -30,7 +30,7 @@ public function __construct(Config $config, Api $api, OutputInterface $output) { * * @return string[] */ - public function defaultKeyNames() + public function defaultKeyNames(): array { return [ 'id_rsa', @@ -57,7 +57,8 @@ public function defaultKeyNames() * An absolute filename of an SSH private key, or null if there is no * selected key. */ - public function selectIdentity($reset = false) { + public function selectIdentity(bool $reset = false): ?string + { // Cache, mainly to avoid repetition of the output message. static $selectedIdentity = false; if (!$reset && $selectedIdentity !== false) { @@ -79,7 +80,7 @@ public function selectIdentity($reset = false) { $publicKeys = $this->listPublicKeys(true); if (\count($publicKeys) === 1) { $filename = \reset($publicKeys) ?: ''; - $identityFile = \substr($filename, 0, \strlen($filename) - 4); + $identityFile = \substr((string) $filename, 0, \strlen((string) $filename) - 4); if (\in_array(\basename($identityFile), $this->defaultKeyNames(), true)) { return null; } @@ -94,13 +95,11 @@ public function selectIdentity($reset = false) { } /** - * List existing public keys. + * Lists existing public key files. * - * @param bool $reset - * - * @return array + * @return string[] */ - private function listPublicKeys($reset = false) + private function listPublicKeys(bool $reset = false): array { static $publicKeyList; if (!isset($publicKeyList) || $reset) { @@ -114,22 +113,20 @@ private function listPublicKeys($reset = false) * * @return string[] */ - private function listAccountKeyFingerprints() + private function listAccountKeyFingerprints(): array { $keys = $this->api->getSshKeys(); if (!count($keys)) { return []; } - return \array_map(function (SshKeyModel $sshKey) { - return $sshKey->fingerprint; - }, $keys); + return \array_map(fn(SshKeyModel $sshKey) => $sshKey->fingerprint, $keys); } /** * Checks whether the user has an SSH key in ~/.ssh matching their account. */ - public function hasLocalKey() + public function hasLocalKey(): bool { return $this->findIdentityMatchingPublicKeys($this->listAccountKeyFingerprints()) !== null; } @@ -140,7 +137,7 @@ public function hasLocalKey() * @return string|null * The filename of the key, or null if none is found. */ - public function findIdentityMatchingPublicKeys(array $fingerprints) + public function findIdentityMatchingPublicKeys(array $fingerprints): ?string { foreach ($this->listPublicKeys() as $publicKey) { $privateKey = \substr($publicKey, 0, \strlen($publicKey) - 4); @@ -169,15 +166,16 @@ public function findIdentityMatchingPublicKeys(array $fingerprints) * * @return string */ - public function getPublicKeyFingerprint($filename) { + public function getPublicKeyFingerprint(string $filename): string + { $contents = \file_get_contents($filename); if ($contents === false) { throw new \RuntimeException('Failed to read file: ' . $filename); } - if (\strpos($contents, ' ') === false) { + if (!str_contains($contents, ' ')) { throw new \RuntimeException('Invalid public key: ' . $filename); } - list(, $keyB64) = \explode(' ', $contents, 3); + [, $keyB64] = \explode(' ', $contents, 3); $key = \base64_decode($keyB64, true); if ($key === false) { throw new \RuntimeException('Failed to base64-decode public key: ' . $filename); diff --git a/src/Service/State.php b/src/Service/State.php index b0ad62a170..26f5a5339f 100644 --- a/src/Service/State.php +++ b/src/Service/State.php @@ -1,5 +1,7 @@ */ + protected array $state = []; - protected $state = []; + protected bool $loaded = false; - protected $loaded = false; + public function __construct(protected readonly Config $config) {} /** - * @param Config $config - */ - public function __construct(Config $config) - { - $this->config = $config; - } - - /** - * @param string $key + * Gets a state value. * * @return mixed|false * The value, or false if the value does not exist. */ - public function get($key) + public function get(string $key): mixed { $this->load(); $value = NestedArrayUtil::getNestedArrayValue($this->state, explode('.', $key), $exists); @@ -39,13 +34,9 @@ public function get($key) } /** - * Set a state value. - * - * @param string $key - * @param mixed $value - * @param bool $save + * Sets a state value. */ - public function set($key, $value, $save = true) + public function set(string $key, mixed $value, bool $save = true): void { $this->load(); $parents = explode('.', $key); @@ -59,25 +50,25 @@ public function set($key, $value, $save = true) } /** - * Save state. + * Saves state. */ - public function save() + public function save(): void { (new SymfonyFilesystem())->dumpFile( $this->getFilename(), - json_encode($this->state) + (string) json_encode($this->state), ); } /** * Load state. */ - protected function load() + protected function load(): void { if (!$this->loaded) { $filename = $this->getFilename(); if (file_exists($filename)) { - $content = file_get_contents($filename); + $content = (string) file_get_contents($filename); $this->state = json_decode($content, true) ?: []; } $this->loaded = true; @@ -87,7 +78,7 @@ protected function load() /** * @return string */ - protected function getFilename() + protected function getFilename(): string { return $this->config->getWritableUserDir() . DIRECTORY_SEPARATOR . $this->config->getWithDefault('application.user_state_file', 'state.json'); } diff --git a/src/Service/SubCommandRunner.php b/src/Service/SubCommandRunner.php new file mode 100644 index 0000000000..60b98d9bd5 --- /dev/null +++ b/src/Service/SubCommandRunner.php @@ -0,0 +1,94 @@ +stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; + } + + /** + * Runs another CLI command. + * + * @param string $commandName + * @param array $arguments + * @param OutputInterface|null $output + * + * @return int + * @throws \Exception + */ + public function run(string $commandName, array $arguments = [], ?OutputInterface $output = null): int + { + $application = new Application($this->config); + $application->setAutoExit(false); + $application->setIO($this->input, $output ?: $this->output); + $command = $application->find($commandName); + + $this->forwardStandardOptions($arguments, $this->input, $command->getDefinition()); + + $cmdInput = new ArrayInput(['command' => $commandName] + $arguments); + if (!empty($arguments['--yes']) || !empty($arguments['--no'])) { + $cmdInput->setInteractive(false); + } else { + $cmdInput->setInteractive($this->input->isInteractive()); + } + + $this->stdErr->writeln( + 'DEBUG Running sub-command: ' . $command->getName() . '', + OutputInterface::VERBOSITY_DEBUG, + ); + + return $application->run($cmdInput, $output ?: $this->output); + } + + /** + * Forwards standard (unambiguous) arguments that a source and target command have in common. + * + * @param array &$args + * @param InputInterface $input + * @param InputDefinition $targetDef + */ + private function forwardStandardOptions(array &$args, InputInterface $input, InputDefinition $targetDef): void + { + $stdOptions = [ + 'no', + 'no-interaction', + 'yes', + + 'no-wait', + 'wait', + + 'org', + 'host', + 'project', + 'environment', + 'app', + 'worker', + 'instance', + ]; + foreach ($stdOptions as $name) { + if (!\array_key_exists('--' . $name, $args) && $targetDef->hasOption($name) && $input->hasOption($name)) { + $value = $input->getOption($name); + if ($value !== null && $value !== false) { + $args['--' . $name] = $value; + } + } + } + } +} diff --git a/src/Service/Table.php b/src/Service/Table.php index 7a7324bdb6..75adb26b2d 100644 --- a/src/Service/Table.php +++ b/src/Service/Table.php @@ -1,5 +1,7 @@ * // Create a command property $tableHeader; - * private $tableHeader = ['Column 1', 'Column 2', 'Column 3']; + * private array $tableHeader = ['Column 1', 'Column 2', 'Column 3']; * * // In a command's configure() method, add the --format and --columns options: * Table::configureInput($this->getDefinition(), $this->tableHeader); @@ -36,29 +40,22 @@ */ class Table implements InputConfiguringInterface { - protected $output; - protected $input; - /** * @param InputInterface $input * @param OutputInterface $output */ - public function __construct(InputInterface $input, OutputInterface $output) - { - $this->output = $output; - $this->input = $input; - } + public function __construct(protected InputInterface $input, protected OutputInterface $output) {} /** * Add the --format and --columns options to a command's input definition. * * @param InputDefinition $definition - * @param array $columns + * @param array $columns * The table header or a list of available columns. * @param string[] $defaultColumns * A list of default columns. */ - public static function configureInput(InputDefinition $definition, array $columns = [], array $defaultColumns = []) + public static function configureInput(InputDefinition $definition, array $columns = [], array $defaultColumns = []): void { $description = 'The output format: table, csv, tsv, or plain'; $option = new InputOption('format', null, InputOption::VALUE_REQUIRED, $description, 'table'); @@ -66,10 +63,10 @@ public static function configureInput(InputDefinition $definition, array $column $description = 'Columns to display.'; if (!empty($columns)) { if (!empty($defaultColumns)) { - $description .= "\n" . 'Available columns: ' . static::formatAvailableColumns($columns, $defaultColumns) . ' (* = default columns).'; + $description .= "\n" . 'Available columns: ' . self::formatAvailableColumns($columns, $defaultColumns) . ' (* = default columns).'; $description .= "\n" . 'The character "+" can be used as a placeholder for the default columns.'; } else { - $description .= "\n" . 'Available columns: ' . static::formatAvailableColumns($columns) . '.'; + $description .= "\n" . 'Available columns: ' . self::formatAvailableColumns($columns) . '.'; } } $description .= "\n" . Wildcard::HELP . "\n" . ArrayArgument::SPLIT_HELP; @@ -82,20 +79,20 @@ public static function configureInput(InputDefinition $definition, array $column } /** - * @param array $columns + * @param array $columns * @param string[] $defaultColumns * @param bool $markDefault * @return string */ - private static function formatAvailableColumns($columns, $defaultColumns = [], $markDefault = true) + private static function formatAvailableColumns(array $columns, array $defaultColumns = [], bool $markDefault = true): string { - $columnNames = array_keys(static::availableColumns($columns)); + $columnNames = array_keys(self::availableColumns($columns)); natcasesort($columnNames); if ($defaultColumns) { $defaultColumns = array_map('\strtolower', $defaultColumns); $columnNames = array_diff($columnNames, $defaultColumns); if ($markDefault) { - $defaultColumns = array_map(function ($c) { return $c . '*'; }, $defaultColumns); + $defaultColumns = array_map(fn($c): string => $c . '*', $defaultColumns); } $columnNames = array_merge($defaultColumns, $columnNames); } @@ -106,13 +103,13 @@ private static function formatAvailableColumns($columns, $defaultColumns = [], $ /** * Modifies the input to replace deprecated column names, and outputs a warning for each. * - * @param array $replacements + * @param array $replacements * @param InputInterface $input * @param OutputInterface $output * * @return void */ - public function replaceDeprecatedColumns(array $replacements, InputInterface $input, OutputInterface $output) + public function replaceDeprecatedColumns(array $replacements, InputInterface $input, OutputInterface $output): void { $stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; $columns = $this->specifiedColumns(); @@ -128,14 +125,14 @@ public function replaceDeprecatedColumns(array $replacements, InputInterface $in /** * Modifies the input to remove deprecated columns, and outputs a warning for each. * - * @param array $remove A list of column names to remove. + * @param string[] $remove A list of column names to remove. * @param string $placeholder The name of a placeholder column to display in place of the removed one. * @param InputInterface $input * @param OutputInterface $output * * @return void */ - public function removeDeprecatedColumns(array $remove, $placeholder, InputInterface $input, OutputInterface $output) + public function removeDeprecatedColumns(array $remove, string $placeholder, InputInterface $input, OutputInterface $output): void { $stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; $columns = $this->specifiedColumns(); @@ -151,10 +148,10 @@ public function removeDeprecatedColumns(array $remove, $placeholder, InputInterf /** * Render an single-dimensional array of values, with their property names. * - * @param string[] $values - * @param string[] $propertyNames + * @param array $values + * @param array $propertyNames */ - public function renderSimple(array $values, array $propertyNames) + public function renderSimple(array $values, array $propertyNames): void { $data = []; foreach ($propertyNames as $key => $label) { @@ -166,11 +163,11 @@ public function renderSimple(array $values, array $propertyNames) /** * Returns the columns to display, based on defaults and user input. * - * @param string[]|array $header + * @param array $header * @param string[] $defaultColumns * @return string[] A list of (lower-case) column names. */ - public function columnsToDisplay(array $header, array $defaultColumns = []) + public function columnsToDisplay(array $header, array $defaultColumns = []): array { $availableColumns = array_keys(self::availableColumns($header)); if (empty($defaultColumns)) { @@ -200,7 +197,7 @@ public function columnsToDisplay(array $header, array $defaultColumns = []) $matched = Wildcard::select($availableColumns, [$requestedCol]); if (empty($matched)) { throw new InvalidArgumentException( - sprintf('Column not found: %s (available columns: %s)', $requestedCol, self::formatAvailableColumns($availableColumns)) + sprintf('Column not found: %s (available columns: %s)', $requestedCol, self::formatAvailableColumns($availableColumns)), ); } $toDisplay = array_merge($toDisplay, $matched); @@ -212,24 +209,26 @@ public function columnsToDisplay(array $header, array $defaultColumns = []) /** * Render a table of data to output. * - * @param array $rows + * @param array|TableSeparator> $rows * The table rows. - * @param string[] $header + * @param array $header * The table header (optional). * @param string[] $defaultColumns * Default columns to display (optional). Columns are identified by * their name in $header, or alternatively by their key in $rows. */ - public function render(array $rows, array $header = [], array $defaultColumns = []) + public function render(array $rows, array $header = [], array $defaultColumns = []): void { $format = $this->getFormat(); $columnsToDisplay = $this->columnsToDisplay($header, $defaultColumns); $rows = $this->filterColumns($rows, $header, $columnsToDisplay); - $header = $this->filterColumns([0 => $header], $header, $columnsToDisplay)[0]; if ($this->input->hasOption('no-header') && $this->input->getOption('no-header')) { $header = []; + } else { + /** @var array $header */ + $header = $this->filterColumns([0 => $header], $header, $columnsToDisplay)[0]; } switch ($format) { @@ -262,7 +261,7 @@ public function render(array $rows, array $header = [], array $defaultColumns = * True if the user has specified a machine-readable format via the * --format option (e.g. 'csv' or 'tsv'), false otherwise. */ - public function formatIsMachineReadable() + public function formatIsMachineReadable(): bool { return in_array($this->getFormat(), ['csv', 'tsv', 'plain']); } @@ -270,9 +269,9 @@ public function formatIsMachineReadable() /** * Get the columns specified by the user. * - * @return array + * @return string[] */ - protected function specifiedColumns() + protected function specifiedColumns(): array { if (!$this->input->hasOption('columns')) { return []; @@ -280,9 +279,9 @@ protected function specifiedColumns() $val = $this->input->getOption('columns'); if (\count($val) === 1) { $first = \reset($val); - if (\strpos($first, '+') !== false) { - $first = preg_replace('/([\w%])\+/', '$1,+', $first); - $first = preg_replace('/\+([\w%])/', '+,$1', $first); + if (str_contains((string) $first, '+')) { + $first = preg_replace('/([\w%])\+/', '$1,+', (string) $first); + $first = preg_replace('/\+([\w%])/', '+,$1', (string) $first); $val = [$first]; } } @@ -292,15 +291,15 @@ protected function specifiedColumns() /** * Returns the available columns, which are all the (lower-cased) values and string keys in the header. * - * @param array $header - * @return array + * @param array $header + * @return array */ - private static function availableColumns(array $header) + private static function availableColumns(array $header): array { $availableColumns = []; foreach ($header as $key => $column) { $columnName = \is_string($key) ? $key : $column; - $availableColumns[\strtolower($columnName)] = $key; + $availableColumns[\strtolower((string) $columnName)] = $key; } return $availableColumns; } @@ -308,13 +307,13 @@ private static function availableColumns(array $header) /** * Filter rows by column names. * - * @param array $rows - * @param array $header + * @param array|TableSeparator> $rows + * @param array $header * @param string[] $columnsToDisplay * - * @return array + * @return array|TableSeparator> */ - private function filterColumns(array $rows, array $header, array $columnsToDisplay) + private function filterColumns(array $rows, array $header, array $columnsToDisplay): array { if (empty($columnsToDisplay)) { return $rows; @@ -333,7 +332,7 @@ private function filterColumns(array $rows, array $header, array $columnsToDispl } $newRow = []; foreach ($columnsToDisplay as $columnNameLowered) { - $keyFromHeader = isset($availableColumns[$columnNameLowered]) ? $availableColumns[$columnNameLowered] : false; + $keyFromHeader = $availableColumns[$columnNameLowered] ?? false; if ($keyFromHeader !== false && array_key_exists($keyFromHeader, $row)) { $newRow[] = $row[$keyFromHeader]; continue; @@ -356,27 +355,27 @@ private function filterColumns(array $rows, array $header, array $columnsToDispl * * @return string|null */ - protected function getFormat() + protected function getFormat(): ?string { if ($this->input->hasOption('format') && $this->input->getOption('format')) { - return strtolower($this->input->getOption('format')); + return strtolower((string) $this->input->getOption('format')); } return null; } /** - * Render CSV output. + * Renders CSV output. * - * @param array $rows - * @param array $header - * @param string $delimiter + * @param array|TableSeparator> $rows + * @param array $header */ - protected function renderCsv(array $rows, array $header, $delimiter = ',') + protected function renderCsv(array $rows, array $header, string $delimiter = ','): void { if (!empty($header)) { array_unshift($rows, $header); } + // Remove TableSeparator objects. $rows = array_filter($rows, '\\is_array'); // RFC 4180 (the closest thing to a CSV standard) asks for CRLF line // breaks, but these do not play nicely with POSIX shells whose @@ -388,14 +387,15 @@ protected function renderCsv(array $rows, array $header, $delimiter = ',') /** * Render plain, line-based output. * - * @param array $rows - * @param array $header + * @param array|TableSeparator> $rows + * @param array $header */ - protected function renderPlain(array $rows, array $header) + protected function renderPlain(array $rows, array $header): void { if (!empty($header)) { array_unshift($rows, $header); } + // Remove TableSeparator objects. $rows = array_filter($rows, '\\is_array'); $this->output->write((new PlainFormat())->format($rows)); } @@ -403,10 +403,10 @@ protected function renderPlain(array $rows, array $header) /** * Render a Symfony Console table. * - * @param array $rows - * @param array $header + * @param array|TableSeparator> $rows + * @param array $header */ - protected function renderTable(array $rows, array $header) + protected function renderTable(array $rows, array $header): void { $table = new AdaptiveTable($this->output); $table->setHeaders($header); diff --git a/src/Service/TokenConfig.php b/src/Service/TokenConfig.php index 544f73abce..6b3045daee 100644 --- a/src/Service/TokenConfig.php +++ b/src/Service/TokenConfig.php @@ -1,34 +1,29 @@ config = $config ?: new Config(); $this->apiTokenStorage = Storage::factory($this->config); } - /** - * @return \Platformsh\Cli\ApiToken\StorageInterface - */ - public function storage() + public function storage(): StorageInterface { return $this->apiTokenStorage; } - /** - * @param bool $includeStored - * - * @return string|null - */ - public function getApiToken($includeStored = true) + public function getApiToken(bool $includeStored = true): ?string { if ($includeStored) { $storedToken = $this->apiTokenStorage->getToken(); @@ -50,25 +45,22 @@ public function getApiToken($includeStored = true) return null; } - /** - * @return string|null - */ - public function getAccessToken() + public function getAccessToken(): ?string { return $this->config->getWithDefault('api.access_token', null); } /** - * Load an API token from a file. + * Loads an API token from a file. * * @param string $filename * A filename, either relative to the user config directory, or absolute. * * @return string */ - private function loadTokenFromFile($filename) + private function loadTokenFromFile(string $filename): string { - if (strpos($filename, '/') !== 0 && strpos($filename, '\\') !== 0) { + if (!str_starts_with($filename, '/') && !str_starts_with($filename, '\\')) { $filename = $this->config->getUserConfigDir() . '/' . $filename; } diff --git a/src/Service/TunnelManager.php b/src/Service/TunnelManager.php new file mode 100644 index 0000000000..a8be21ead1 --- /dev/null +++ b/src/Service/TunnelManager.php @@ -0,0 +1,302 @@ +} $metadata + * @return string + */ + private function getId(array $metadata): string + { + return implode('--', [ + $metadata['projectId'], + $metadata['environmentId'], + $metadata['appName'] ?? '', + $metadata['relationship'], + $metadata['serviceKey'], + ]); + } + + /** + * @param array $service + * + * @throws \Exception + */ + public function create(Selection $selection, array $service, ?int $localPort = null): Tunnel + { + $metadata = [ + 'projectId' => $selection->getProject()->id, + 'environmentId' => $selection->getEnvironment()->id, + 'appName' => $selection->getAppName(), + 'relationship' => $service['_relationship_name'], + 'serviceKey' => $service['_relationship_key'], + 'service' => $service, + ]; + + return new Tunnel($this->getId($metadata), $localPort ?: $this->getPort(), $service['host'], $service['port'], $metadata); + } + + /** + * Automatically determines the best port for a new tunnel. + * @throws \Exception + */ + protected function getPort(int $default = 30000): int + { + $ports = []; + foreach ($this->getTunnels() as $tunnel) { + $ports[] = $tunnel->localPort; + } + + return PortUtil::getPort($ports ? max($ports) + 1 : $default); + } + + /** + * Gets info on currently open tunnels. + * + * @return Tunnel[] + */ + public function getTunnels(bool $open = true): array + { + if (!isset($this->tunnels)) { + $this->tunnels = []; + // @todo move this to State service (in a new major version) + $filename = $this->config->getWritableUserDir() . '/tunnel-info.json'; + if (file_exists($filename)) { + $this->io->debug(sprintf('Loading tunnel info from %s', $filename)); + $this->tunnels = $this->unserialize((string) file_get_contents($filename)); + } + } + + if ($open) { + $needsSave = false; + foreach ($this->tunnels as $key => $tunnel) { + if ($tunnel->pid === null) { + $this->io->debug(sprintf( + 'No PID found for the tunnel at port %d; removing from list', + $tunnel->localPort, + )); + unset($this->tunnels[$key]); + $needsSave = true; + } elseif (function_exists('posix_kill') && !posix_kill($tunnel->pid, 0)) { + $this->io->debug(sprintf( + 'The tunnel at port %d is no longer open, removing from list', + $tunnel->localPort, + )); + unset($this->tunnels[$key]); + $needsSave = true; + } + } + if ($needsSave) { + $this->saveTunnelInfo(); + } + } + + return $this->tunnels; + } + + public function saveNewTunnel(Tunnel $tunnel, int $pid): void + { + $tunnel->pid = $pid; + $this->tunnels[] = $tunnel; + $this->saveTunnelInfo(); + } + + private function saveTunnelInfo(): void + { + $filename = $this->config->getWritableUserDir() . '/tunnel-info.json'; + if (!empty($this->tunnels)) { + $this->io->debug('Saving tunnel info to: ' . $filename); + if (!file_put_contents($filename, $this->serialize($this->tunnels))) { + throw new \RuntimeException('Failed to write tunnel info to: ' . $filename); + } + } else { + unlink($filename); + } + } + + /** + * Checks whether a tunnel is already open. + * + * @return false|Tunnel + * If the tunnel is open, a new Tunnel object is returned with its PID + * set. + */ + public function isOpen(Tunnel $tunnel): false|Tunnel + { + foreach ($this->tunnels as $t) { + if ($t->id === $tunnel->id) { + if ($t->pid && function_exists('posix_kill') && !posix_kill($t->pid, 0)) { + $this->io->debug(sprintf( + 'The tunnel at port %d is no longer open, removing from list', + $t->localPort, + )); + $this->close($t); + return false; + } + $tunnel->pid = $t->pid; + + return $tunnel; + } + } + + return false; + } + + /** + * @param Tunnel[] $tunnels + * @throws \JsonException + */ + private function serialize(array $tunnels): string + { + $data = []; + foreach ($tunnels as $tunnel) { + $data[$tunnel->id] = $tunnel->metadata + [ + 'id' => $tunnel->id, + 'localPort' => $tunnel->localPort, + 'remoteHost' => $tunnel->remoteHost, + 'remotePort' => $tunnel->remotePort, + 'pid' => $tunnel->pid, + ]; + } + + return \json_encode($data, JSON_THROW_ON_ERROR); + } + + /** + * @return Tunnel[] + */ + private function unserialize(string $jsonData): array + { + $tunnels = []; + $data = (array) json_decode($jsonData, true); + foreach ($data as $item) { + $metadata = $item; + unset($metadata['id'], $metadata['localPort'], $metadata['remoteHost'], $metadata['remotePort'], $metadata['pid']); + $tunnels[] = new Tunnel($item['id'], $item['localPort'], $item['remoteHost'], $item['remotePort'], $metadata, $item['pid']); + } + return $tunnels; + } + + /** + * Closes an open tunnel. + */ + public function close(Tunnel $tunnel): void + { + if ($tunnel->pid !== null && function_exists('posix_kill')) { + if (!posix_kill($tunnel->pid, SIGTERM)) { + throw new \RuntimeException(sprintf( + 'Failed to kill process %d (POSIX error: %s)', + $tunnel->pid, + posix_get_last_error(), + )); + } + } + $pidFile = $this->getPidFilename($tunnel); + if (file_exists($pidFile) && !unlink($pidFile)) { + throw new \RuntimeException(sprintf( + 'Failed to delete file: %s', + $pidFile, + )); + } + } + + public function getPidFilename(Tunnel $tunnel): string + { + $dir = $this->config->getWritableUserDir() . '/.tunnels'; + if (!is_dir($dir) && !mkdir($dir, 0o700, true)) { + throw new \RuntimeException('Failed to create directory: ' . $dir); + } + + return $dir . '/' . preg_replace('/[^0-9a-z.]+/', '-', $tunnel->id) . '.pid'; + } + + /** + * @param string[] $extraArgs + */ + public function createProcess(string $url, Tunnel $tunnel, array $extraArgs = []): Process + { + $args = ['ssh', '-n', '-N', '-L', implode(':', [$tunnel->localPort, $tunnel->remoteHost, $tunnel->remotePort]), $url]; + $args = array_merge($args, $extraArgs); + $process = new Process($args); + $process->setTimeout(null); + + return $process; + } + + /** + * Filters a list of tunnels by the currently selected project/environment. + * + * @param Tunnel[] $tunnels + * + * @return Tunnel[] + */ + public function filterBySelection(array $tunnels, Selection $selection): array + { + if (!$selection->hasProject()) { + return $tunnels; + } + $project = $selection->getProject(); + $environment = $selection->hasEnvironment() ? $selection->getEnvironment() : null; + $appName = $selection->hasEnvironment() ? $selection->getAppName() : null; + foreach ($tunnels as $key => $tunnel) { + $metadata = $tunnel->metadata; + if ($metadata['projectId'] !== $project->id + || ($environment !== null && $metadata['environmentId'] !== $environment->id) + || ($appName !== null && $metadata['appName'] !== $appName)) { + unset($tunnels[$key]); + } + } + + return $tunnels; + } + + public function getUrl(Tunnel $tunnel): string + { + $localService = array_merge($tunnel->metadata['service'], array_intersect_key([ + 'host' => self::LOCAL_IP, + 'port' => $tunnel->localPort, + ], $tunnel->metadata['service'])); + + return $this->relationships->buildUrl($localService); + } + + /** + * Formats a tunnel's relationship as a string. + */ + public function formatRelationship(Tunnel $tunnel): string + { + $metadata = $tunnel->metadata; + + return $metadata['serviceKey'] > 0 + ? sprintf('%s.%d', $metadata['relationship'], $metadata['serviceKey']) + : $metadata['relationship']; + } + + public function openLog(string $logFile): OutputInterface|false + { + $logResource = fopen($logFile, 'a'); + if ($logResource) { + return new StreamOutput($logResource, OutputInterface::VERBOSITY_VERBOSE); + } + + return false; + } +} diff --git a/src/Service/Url.php b/src/Service/Url.php index c5eb48c8e1..6b3005581a 100644 --- a/src/Service/Url.php +++ b/src/Service/Url.php @@ -1,5 +1,7 @@ shell = $shell; - $this->input = $input; - $this->output = $output; $this->stdErr = $this->output instanceof ConsoleOutputInterface ? $this->output->getErrorOutput() : $this->output; } /** - * @param \Symfony\Component\Console\Input\InputDefinition $definition + * @param InputDefinition $definition */ - public static function configureInput(InputDefinition $definition) + public static function configureInput(InputDefinition $definition): void { $definition->addOption(new InputOption( 'browser', null, InputOption::VALUE_REQUIRED, - 'The browser to use to open the URL. Set 0 for none.' + 'The browser to use to open the URL. Set 0 for none.', )); $definition->addOption(new InputOption( 'pipe', null, InputOption::VALUE_NONE, - 'Output the URL to stdout.' + 'Output the URL to stdout.', )); } @@ -50,22 +46,18 @@ public static function configureInput(InputDefinition $definition) * * @return bool */ - public function canOpenUrls() + public function canOpenUrls(): bool { return $this->hasDisplay() && $this->getBrowser($this->input->hasOption('browser') ? $this->input->getOption('browser') : null) !== false; } /** - * Open a URL in the browser, or print it. - * - * @param string $url - * @param bool $print + * Opens a URL in the browser, or prints it. * - * @return bool - * True if a browser was used, false otherwise. + * @return bool True if a browser was used, false otherwise. */ - public function openUrl($url, $print = true) + public function openUrl(string $url, bool $print = true): bool { $browserOption = $this->input->hasOption('browser') ? $this->input->getOption('browser') : null; $open = true; @@ -116,7 +108,7 @@ public function openUrl($url, $print = true) * * @return bool */ - public function hasDisplay() + public function hasDisplay(): bool { if (getenv('DISPLAY')) { return getenv('DISPLAY') !== 'none'; @@ -133,12 +125,12 @@ public function hasDisplay() * @return string|false A browser command, or false if no browser can or * should be used. */ - private function getBrowser($browserOption = null) + private function getBrowser(?string $browserOption = null): string|false { if ($browserOption === '0') { return false; } elseif (!empty($browserOption)) { - list($command, ) = explode(' ', $browserOption, 2); + [$command, ] = explode(' ', $browserOption, 2); if (!$this->shell->commandExists($command)) { $this->stdErr->writeln(sprintf('Command not found: %s', $command)); return false; @@ -155,7 +147,7 @@ private function getBrowser($browserOption = null) * * @return string|false */ - private function getDefaultBrowser() + private function getDefaultBrowser(): string|false { if (OsUtil::isWindows()) { return 'start'; diff --git a/src/Command/Variable/VariableCommandBase.php b/src/Service/VariableCommandUtil.php similarity index 66% rename from src/Command/Variable/VariableCommandBase.php rename to src/Service/VariableCommandUtil.php index d2f53b3f10..5053ad3b73 100644 --- a/src/Command/Variable/VariableCommandBase.php +++ b/src/Service/VariableCommandUtil.php @@ -1,42 +1,48 @@ escapeToken($str); + private OutputInterface $stdErr; + + public function __construct( + private readonly Api $api, + private readonly Config $config, + private readonly PropertyFormatter $propertyFormatter, + private readonly Table $table, + OutputInterface $output, + ) { + $this->stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; } /** * Add the --level option. */ - protected function addLevelOption() + public function addLevelOption(InputDefinition $definition): void { - $this->addOption('level', 'l', InputOption::VALUE_REQUIRED, "The variable level ('project', 'environment', 'p' or 'e')"); + $definition->addOption(new InputOption('level', 'l', InputOption::VALUE_REQUIRED, "The variable level ('project', 'environment', 'p' or 'e')")); } /** @@ -46,14 +52,14 @@ protected function addLevelOption() * * @return string|null */ - protected function getRequestedLevel(InputInterface $input) + public function getRequestedLevel(InputInterface $input): ?string { $str = $input->getOption('level'); if (empty($str)) { return null; } foreach ([self::LEVEL_PROJECT, self::LEVEL_ENVIRONMENT] as $validLevel) { - if (stripos($validLevel, $str) === 0) { + if (stripos($validLevel, (string) $str) === 0) { return $validLevel; } } @@ -63,21 +69,22 @@ protected function getRequestedLevel(InputInterface $input) /** * Finds an existing variable by name. * - * @param string $name + * @param string $name + * @param Selection $selection * @param string|null $level - * @param bool $messages Whether to print error messages to + * @param bool $messages Whether to print error messages to * $this->stdErr if the variable is not found. * - * @return \Platformsh\Client\Model\ProjectLevelVariable|\Platformsh\Client\Model\Variable|false + * @return ProjectLevelVariable|EnvironmentLevelVariable|false */ - protected function getExistingVariable($name, $level = null, $messages = true) + public function getExistingVariable(string $name, Selection $selection, ?string $level, bool $messages = true): EnvironmentLevelVariable|false|ProjectLevelVariable { $output = $messages ? $this->stdErr : new NullOutput(); - if ($level === self::LEVEL_ENVIRONMENT || ($this->hasSelectedEnvironment() && $level === null)) { - $variable = $this->getSelectedEnvironment()->getVariable($name); + if ($level === self::LEVEL_ENVIRONMENT || ($selection->hasEnvironment() && $level === null)) { + $variable = $selection->getEnvironment()->getVariable($name); if ($variable !== false) { - if ($level === null && $this->getSelectedProject()->getVariable($name)) { + if ($level === null && $selection->getProject()->getVariable($name)) { $output->writeln('Variable found at both project and environment levels: ' . $name . ''); $output->writeln("To select a variable, use the --level option ('" . self::LEVEL_PROJECT . "' or '" . self::LEVEL_ENVIRONMENT . "')."); @@ -88,7 +95,7 @@ protected function getExistingVariable($name, $level = null, $messages = true) } } if ($level !== self::LEVEL_ENVIRONMENT) { - $variable = $this->getSelectedProject()->getVariable($name); + $variable = $selection->getProject()->getVariable($name); if ($variable !== false) { return $variable; } @@ -100,15 +107,11 @@ protected function getExistingVariable($name, $level = null, $messages = true) /** * Display a variable to stdout. - * - * @param \Platformsh\Client\Model\Resource $variable */ - protected function displayVariable(ApiResource $variable) + public function displayVariable(ApiResourceBase $variable): void { - /** @var \Platformsh\Cli\Service\Table $table */ - $table = $this->getService('table'); - /** @var \Platformsh\Cli\Service\PropertyFormatter $formatter */ - $formatter = $this->getService('property_formatter'); + $table = $this->table; + $formatter = $this->propertyFormatter; $properties = $variable->getProperties(); $properties['level'] = $this->getVariableLevel($variable); @@ -118,19 +121,14 @@ protected function displayVariable(ApiResource $variable) foreach ($properties as $key => $value) { $headings[] = new AdaptiveTableCell($key, ['wrap' => false]); if ($key === 'value') { - $value = wordwrap($value, 80, "\n", true); + $value = wordwrap((string) $value, 80, "\n", true); } $values[] = $formatter->format($value, $key); } $table->renderSimple($values, $headings); } - /** - * @param ApiResource $variable - * - * @return string - */ - protected function getVariableLevel(ApiResource $variable) + public function getVariableLevel(ApiResourceBase $variable): string { if ($variable instanceof EnvironmentLevelVariable) { return self::LEVEL_ENVIRONMENT; @@ -141,9 +139,10 @@ protected function getVariableLevel(ApiResource $variable) } /** - * @return Field[] + * @param callable(): Selection $getSelection + * @return array */ - protected function getFields() + public function getFields(callable $getSelection): array { $fields = []; @@ -168,33 +167,21 @@ protected function getFields() 'conditions' => [ 'level' => self::LEVEL_ENVIRONMENT, ], - 'optionName' => false, 'questionLine' => 'On what environment should the variable be set?', - 'optionsCallback' => function () { - return array_keys($this->api()->getEnvironments($this->getSelectedProject())); - }, + 'optionsCallback' => fn(): array => array_keys($this->api->getEnvironments($getSelection()->getProject())), 'asChoice' => false, 'includeAsOption' => false, - 'defaultCallback' => function () { - if ($this->hasSelectedEnvironment()) { - return $this->getSelectedEnvironment()->id; - } - return null; - }, + 'defaultCallback' => fn(): ?string => $getSelection()->hasEnvironment() ? $getSelection()->getEnvironment()->id : null, ]); $fields['name'] = new Field('Name', [ 'description' => 'The variable name', 'validators' => [ - function ($value) { - return strlen($value) > 256 - ? 'The variable name exceeds the maximum length, 256 characters.' - : true; - }, - function ($value) { - return strpos($value, ' ') !== false - ? 'The variable name must not contain a space.' - : true; - }, + fn($value): string|true => strlen((string) $value) > 256 + ? 'The variable name exceeds the maximum length, 256 characters.' + : true, + fn($value): string|true => str_contains((string) $value, ' ') + ? 'The variable name must not contain a space.' + : true, ], ]); $fields['value'] = new Field('Value', [ @@ -215,14 +202,10 @@ function ($value) { $fields['prefix'] = new OptionsField('Prefix', [ 'description' => "The variable name's prefix which can determine its type, e.g. 'env'. Only applicable if the name does not already contain a prefix.", 'conditions' => [ - 'name' => function ($name) { - return strpos($name, ':') === false; - } + 'name' => fn($name): bool => !str_contains((string) $name, ':'), ], 'options' => $this->getPrefixOptions('NAME'), - 'optionsCallback' => function (array $previousValues) { - return $this->getPrefixOptions(isset($previousValues['name']) ? $previousValues['name'] : 'NAME'); - }, + 'optionsCallback' => fn(array $previousValues) => $this->getPrefixOptions($previousValues['name'] ?? 'NAME'), 'allowOther' => true, 'default' => 'none', ]); @@ -247,12 +230,11 @@ function ($value) { 'optionName' => 'visible-build', 'description' => 'Whether the variable should be visible at build time', 'questionLine' => 'Should the variable be available at build time?', - 'defaultCallback' => function (array $values) { + 'defaultCallback' => fn(array $values): bool => // Variables that are visible at build-time will affect the // build cache, so it is good to minimise the number of them. // This defaults to true for project-level variables, false otherwise. - return isset($values['level']) && $values['level'] === self::LEVEL_PROJECT; - }, + isset($values['level']) && $values['level'] === self::LEVEL_PROJECT, 'avoidQuestion' => true, ]); $fields['visible_runtime'] = new BooleanField('Visible at runtime', [ @@ -268,12 +250,12 @@ function ($value) { /** * @param string $name * - * @return array + * @return array */ - private function getPrefixOptions($name) + private function getPrefixOptions(string $name): array { return [ - 'none' => 'No prefix: The variable will be part of $' . $this->config()->get('service.env_prefix') . 'VARIABLES.', + 'none' => 'No prefix: The variable will be part of $' . $this->config->getStr('service.env_prefix') . 'VARIABLES.', 'env:' => 'env: The variable will be exposed directly, e.g. as $' . strtoupper($name) . '.', ]; } diff --git a/src/SiteAlias/DrushAlias.php b/src/SiteAlias/DrushAlias.php index 4b59508921..b381016478 100644 --- a/src/SiteAlias/DrushAlias.php +++ b/src/SiteAlias/DrushAlias.php @@ -1,5 +1,7 @@ config = $config; - $this->drush = $drush; - } + public function __construct(protected Config $config, protected Drush $drush) {} /** * {@inheritdoc} */ - public function createAliases(Project $project, $aliasGroup, array $apps, array $environments, $previousGroup = null) + public function createAliases(Project $project, string $aliasGroup, array $apps, array $environments, ?string $previousGroup = null): bool { if (!count($apps)) { return false; @@ -36,7 +31,7 @@ public function createAliases(Project $project, $aliasGroup, array $apps, array // Prepare the Drush directory and file. $aliasDir = $this->drush->getSiteAliasDir(); - if (!is_dir($aliasDir) && !mkdir($aliasDir, 0755, true)) { + if (!is_dir($aliasDir) && !mkdir($aliasDir, 0o755, true)) { throw new \RuntimeException('Drush aliases directory not found: ' . $aliasDir); } if (!is_writable($aliasDir)) { @@ -81,14 +76,14 @@ public function createAliases(Project $project, $aliasGroup, array $apps, array } /** - * Merge new aliases with existing ones. + * Merges new aliases with existing ones. * - * @param array $new - * @param array $existing + * @param array> $new + * @param array> $existing * - * @return array + * @return array> */ - protected function mergeExisting($new, $existing) + private function mergeExisting(array $new, array $existing): array { foreach ($new as $aliasName => &$newAlias) { // If the alias already exists, recursively replace existing @@ -104,11 +99,11 @@ protected function mergeExisting($new, $existing) /** * Normalize the aliases. * - * @param array $aliases + * @param array> $aliases * - * @return array + * @return array> */ - protected function normalize(array $aliases) + protected function normalize(array $aliases): array { return $aliases; } @@ -120,7 +115,7 @@ protected function normalize(array $aliases) * * @return string */ - abstract protected function getFilename($groupName); + abstract protected function getFilename(string $groupName): string; /** * Get the header at the top of the file. @@ -129,25 +124,25 @@ abstract protected function getFilename($groupName); * * @return string */ - abstract protected function getHeader(Project $project); + abstract protected function getHeader(Project $project): string; /** * Find the existing defined aliases so they can be merged with new ones. * - * @param string $currentGroup + * @param string $currentGroup * @param string|null $previousGroup * - * @return array + * @return array> * The aliases, with their group prefixes removed. */ - protected function getExistingAliases($currentGroup, $previousGroup = null) + protected function getExistingAliases(string $currentGroup, ?string $previousGroup = null): array { $aliases = []; foreach (array_filter([$currentGroup, $previousGroup]) as $groupName) { foreach ($this->drush->getAliases($groupName) as $name => $alias) { // Remove the group prefix from the alias name. - $name = ltrim($name, '@'); - if (strpos($name, $groupName . '.') === 0) { + $name = ltrim((string) $name, '@'); + if (str_starts_with($name, $groupName . '.')) { $name = substr($name, strlen($groupName . '.')); } @@ -162,11 +157,11 @@ protected function getExistingAliases($currentGroup, $previousGroup = null) * Generate new aliases. * * @param LocalApplication[] $apps - * @param array $environments + * @param Environment[] $environments * - * @return array + * @return array> */ - protected function generateNewAliases(array $apps, array $environments) + protected function generateNewAliases(array $apps, array $environments): array { $aliases = []; @@ -204,21 +199,21 @@ protected function generateNewAliases(array $apps, array $environments) /** * Format a list of aliases as a string. * - * @param array $aliases + * @param array> $aliases * A list of aliases. * * @return string */ - abstract protected function formatAliases(array $aliases); + abstract protected function formatAliases(array $aliases): string; /** * Generate an alias for the local environment. * - * @param \Platformsh\Cli\Local\LocalApplication $app + * @param LocalApplication $app * - * @return array + * @return array */ - protected function generateLocalAlias(LocalApplication $app) + protected function generateLocalAlias(LocalApplication $app): array { return [ 'root' => $app->getLocalWebRoot(), @@ -234,15 +229,15 @@ protected function generateLocalAlias(LocalApplication $app) * @param Environment $environment * @param LocalApplication $app * - * @return array|false + * @return array|false */ - protected function generateRemoteAlias(Environment $environment, LocalApplication $app) + protected function generateRemoteAlias(Environment $environment, LocalApplication $app): array|false { if (!$environment->hasLink('ssh')) { return false; } - $sshUrl = $environment->getSshUrl($app->getName()); + $sshUrl = $environment->getSshUrl((string) $app->getName()); $alias = [ 'options' => [ @@ -261,7 +256,7 @@ protected function generateRemoteAlias(Environment $environment, LocalApplicatio $alias['root'] = '/app/' . $app->getDocumentRoot(); } - list($alias['user'], $alias['host']) = explode('@', $sshUrl, 2); + [$alias['user'], $alias['host']] = explode('@', $sshUrl, 2); if ($url = $this->drush->getSiteUrl($environment, $app)) { $alias['uri'] = $url; @@ -279,15 +274,15 @@ protected function generateRemoteAlias(Environment $environment, LocalApplicatio * A string based on the application name, for example * 'platformsh-cli-auto-remove'. */ - private function getAutoRemoveKey() + private function getAutoRemoveKey(): string { - return $this->config->get('application.slug') . '-auto-remove'; + return $this->config->getStr('application.slug') . '-auto-remove'; } /** * {@inheritdoc} */ - public function deleteAliases($group) + public function deleteAliases($group): void { $filename = $this->getFilename($group); if (file_exists($filename)) { @@ -298,12 +293,12 @@ public function deleteAliases($group) /** * Swap the key names in an array of aliases. * - * @param array $aliases - * @param array $map + * @param array> $aliases + * @param array $map * - * @return array + * @return array> */ - protected function swapKeys(array $aliases, array $map) + protected function swapKeys(array $aliases, array $map): array { return array_map(function ($alias) use ($map) { foreach ($map as $from => $to) { diff --git a/src/SiteAlias/DrushPhp.php b/src/SiteAlias/DrushPhp.php index d954649edb..11e48be374 100644 --- a/src/SiteAlias/DrushPhp.php +++ b/src/SiteAlias/DrushPhp.php @@ -1,5 +1,7 @@ drush->getSiteAliasDir() . '/' . $groupName . '.aliases.drushrc.php'; } @@ -17,14 +19,14 @@ protected function getFilename($groupName) /** * {@inheritdoc} */ - protected function formatAliases(array $aliases) + protected function formatAliases(array $aliases): string { $formatted = []; foreach ($aliases as $aliasName => $newAlias) { $formatted[] = sprintf( "\$aliases['%s'] = %s;\n", str_replace("'", "\\'", $aliasName), - var_export($newAlias, true) + var_export($newAlias, true), ); } @@ -34,7 +36,7 @@ protected function formatAliases(array $aliases) /** * {@inheritdoc} */ - protected function normalize(array $aliases) + protected function normalize(array $aliases): array { return $this->swapKeys($aliases, [ 'host' => 'remote-host', @@ -45,22 +47,24 @@ protected function normalize(array $aliases) /** * {@inheritdoc} */ - protected function getHeader(Project $project) + protected function getHeader(Project $project): string { - return <<config->get('service.name')} project "{$project->title}". - * - * This file is auto-generated by the {$this->config->get('application.name')}. - * - * WARNING - * This file may be regenerated at any time. - * - User-defined aliases will be preserved. - * - Aliases for active environments (including any custom additions) will be preserved. - * - Aliases for deleted or inactive environments will be deleted. - * - All other information will be deleted. - */ -EOT; + $tpl = <<config->getStr('service.name'), $project->title, $this->config->getStr('application.name')); } } diff --git a/src/SiteAlias/DrushYaml.php b/src/SiteAlias/DrushYaml.php index 130249f3c3..e7ea019c52 100644 --- a/src/SiteAlias/DrushYaml.php +++ b/src/SiteAlias/DrushYaml.php @@ -1,5 +1,7 @@ drush->getSiteAliasDir() . '/' . $groupName . '.site.yml'; } @@ -18,7 +20,7 @@ protected function getFilename($groupName) /** * {@inheritdoc} */ - protected function formatAliases(array $aliases) + protected function formatAliases(array $aliases): string { return Yaml::dump($aliases, 5, 2); } @@ -26,7 +28,7 @@ protected function formatAliases(array $aliases) /** * {@inheritdoc} */ - protected function getExistingAliases($currentGroup, $previousGroup = null) + protected function getExistingAliases(string $currentGroup, $previousGroup = null): array { $aliases = parent::getExistingAliases($currentGroup, $previousGroup); if (empty($aliases)) { @@ -44,7 +46,7 @@ protected function getExistingAliases($currentGroup, $previousGroup = null) /** * {@inheritdoc} */ - protected function normalize(array $aliases) + protected function normalize(array $aliases): array { return $this->swapKeys($aliases, [ 'remote-host' => 'host', @@ -55,18 +57,18 @@ protected function normalize(array $aliases) /** * {@inheritdoc} */ - protected function getHeader(Project $project) + protected function getHeader(Project $project): string { return <<config->get('service.name')} project "{$project->title}". -# This file is auto-generated by the {$this->config->get('application.name')}. -# -# WARNING -# This file may be regenerated at any time. -# - User-defined aliases will be preserved. -# - Aliases for active environments (including any custom additions) will be preserved. -# - Aliases for deleted or inactive environments will be deleted. -# - All other information will be deleted. -EOT; + # Drush aliases for the {$this->config->getStr('service.name')} project "{$project->title}". + # This file is auto-generated by the {$this->config->getStr('application.name')}. + # + # WARNING + # This file may be regenerated at any time. + # - User-defined aliases will be preserved. + # - Aliases for active environments (including any custom additions) will be preserved. + # - Aliases for deleted or inactive environments will be deleted. + # - All other information will be deleted. + EOT; } } diff --git a/src/SiteAlias/SiteAliasTypeInterface.php b/src/SiteAlias/SiteAliasTypeInterface.php index 2f20aa9099..0a07033a55 100644 --- a/src/SiteAlias/SiteAliasTypeInterface.php +++ b/src/SiteAlias/SiteAliasTypeInterface.php @@ -1,30 +1,31 @@ |null */ + private ?array $tokenClaims = null; + /** @var array|null */ + private ?array $inlineAccess = null; - /** - * Certificate constructor. - * - * @param string $certFile - * @param string $privateKeyFile - */ - public function __construct($certFile, $privateKeyFile) + public function __construct(private readonly string $certFile, private readonly string $privateKeyFile) { - $this->certFile = $certFile; - $this->privateKeyFile = $privateKeyFile; $this->contents = \file_get_contents($this->certFile); if (!$this->contents) { throw new \RuntimeException('Failed to read certificate file: ' . $this->certFile); @@ -37,7 +32,7 @@ public function __construct($certFile, $privateKeyFile) * * @return bool */ - public function isIdentical(Certificate $cert) + public function isIdentical(Certificate $cert): bool { return $cert->contents === $this->contents; } @@ -45,7 +40,7 @@ public function isIdentical(Certificate $cert) /** * @return string */ - public function certificateFilename() + public function certificateFilename(): string { return $this->certFile; } @@ -53,7 +48,7 @@ public function certificateFilename() /** * @return string */ - public function privateKeyFilename() + public function privateKeyFilename(): string { return $this->privateKeyFile; } @@ -63,7 +58,7 @@ public function privateKeyFilename() * * @return Metadata */ - public function metadata() + public function metadata(): Metadata { return $this->metadata; } @@ -77,7 +72,8 @@ public function metadata() * * @return bool */ - public function hasExpired($buffer = 120) { + public function hasExpired(int $buffer = 120): bool + { return $this->metadata->getValidBefore() - $buffer < \time(); } @@ -86,7 +82,8 @@ public function hasExpired($buffer = 120) { * * @return bool */ - public function hasMfa() { + public function hasMfa(): bool + { if (\array_key_exists('has-mfa@platform.sh', $this->metadata->getExtensions())) { return true; } @@ -99,7 +96,8 @@ public function hasMfa() { * * @return bool */ - public function isApp() { + public function isApp(): bool + { return \array_key_exists('is-app@platform.sh', $this->metadata->getExtensions()); } @@ -114,11 +112,12 @@ public function isApp() { * act?: array{sub?: string, src?: string} * } */ - public function tokenClaims() { + public function tokenClaims(): array + { if (!isset($this->tokenClaims)) { $ext = $this->metadata->getExtensions(); $this->tokenClaims = isset($ext['token-claims@platform.sh']) - ? json_decode($ext['token-claims@platform.sh'], true) + ? json_decode((string) $ext['token-claims@platform.sh'], true) : []; } return $this->tokenClaims; @@ -129,14 +128,15 @@ public function tokenClaims() { * * @return string[] */ - public function ssoProviders() { + public function ssoProviders(): array + { $tokenClaims = $this->tokenClaims(); if (!isset($tokenClaims['amr'])) { return []; } $ssoProviders = []; foreach ($tokenClaims['amr'] as $authMethod) { - if (strpos($authMethod, 'sso:') === 0) { + if (str_starts_with($authMethod, 'sso:')) { $ssoProviders[] = substr($authMethod, 4); } } @@ -146,13 +146,14 @@ public function ssoProviders() { /** * Returns access info embedded in the certificate. * - * @return array + * @return array */ - public function inlineAccess() { + public function inlineAccess(): array + { if (!isset($this->inlineAccess)) { $ext = $this->metadata->getExtensions(); $this->inlineAccess = isset($ext['access@platform.sh']) - ? json_decode($ext['access@platform.sh'], true) + ? json_decode((string) $ext['access@platform.sh'], true) : []; } return $this->inlineAccess; diff --git a/src/SshCert/Certifier.php b/src/SshCert/Certifier.php index d73e466508..b6aae6b7dd 100644 --- a/src/SshCert/Certifier.php +++ b/src/SshCert/Certifier.php @@ -1,5 +1,7 @@ api = $api; - $this->config = $config; - $this->shell = $shell; - $this->fs = $fs; $this->stdErr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; - $this->fileLock = $fileLock; } /** @@ -40,19 +31,15 @@ public function __construct(Api $api, Config $config, Shell $shell, Filesystem $ * * @return bool */ - public function isAutoLoadEnabled() + public function isAutoLoadEnabled(): bool { - return !self::$disableAutoLoad && $this->config->getWithDefault('ssh.auto_load_cert', false); + return !self::$disableAutoLoad && $this->config->getBool('ssh.auto_load_cert'); } /** * Generates a new certificate. - * - * @param Certificate|null $previousCert - * - * @return Certificate */ - public function generateCertificate($previousCert, $forceNewKey = false) + public function generateCertificate(?Certificate $previousCert, bool $forceNewKey = false): Certificate { // Ensure the user is logged in to the API, so that an auto-login will // not be triggered after we have generated keys (auto-login triggers a @@ -67,7 +54,7 @@ public function generateCertificate($previousCert, $forceNewKey = false) // Acquire a lock to prevent race conditions when certificate and key // files are changed at the same time in different CLI processes. $lockName = 'ssh-cert--' . $this->config->getSessionIdSlug(); - $result = $this->fileLock->acquireOrWait($lockName, function () { + $result = $this->fileLock->acquireOrWait($lockName, function (): void { $this->stdErr->writeln('Waiting for SSH certificate generation lock', OutputInterface::VERBOSITY_VERBOSE); }, function () use ($previousCert) { // While waiting for the lock, check if a new certificate has @@ -89,15 +76,12 @@ public function generateCertificate($previousCert, $forceNewKey = false) /** * Inner function to generate the actual certificate. * - * @param bool $forceNewKey - * @return Certificate - * * @see self::generateCertificate() */ - private function doGenerateCertificate($forceNewKey = false) + private function doGenerateCertificate(bool $forceNewKey = false): Certificate { $dir = $this->config->getSessionDir(true) . DIRECTORY_SEPARATOR . 'ssh'; - $this->fs->mkdir($dir, 0700); + $this->fs->mkdir($dir, 0o700); $privateKeyFilename = $dir . DIRECTORY_SEPARATOR . self::PRIVATE_KEY_FILENAME; $certificateFilename = $privateKeyFilename . '-cert.pub'; @@ -107,7 +91,7 @@ private function doGenerateCertificate($forceNewKey = false) $tempPublicKeyFilename = $tempPrivateKeyFilename . '.pub'; // Remove the old certificate and key from the SSH agent. - if ($this->config->getWithDefault('ssh.add_to_agent', false)) { + if ($this->config->getBool('ssh.add_to_agent')) { $this->shell->execute(['ssh-add', '-d', $privateKeyFilename], null, false, !$this->stdErr->isVeryVerbose()); } @@ -138,7 +122,7 @@ private function doGenerateCertificate($forceNewKey = false) throw new \RuntimeException('Failed to write file: ' . $tempCertificateFilename); } - if (!chmod($tempCertificateFilename, 0600)) { + if (!chmod($tempCertificateFilename, 0o600)) { throw new \RuntimeException('Failed to change permissions on file: ' . $tempCertificateFilename); } @@ -156,9 +140,9 @@ private function doGenerateCertificate($forceNewKey = false) // Add the key to the SSH agent, if possible, silently. // In verbose mode the full command will be printed, so the user can // re-run it to check error details. - if ($this->config->getWithDefault('ssh.add_to_agent', false)) { + if ($this->config->getBool('ssh.add_to_agent')) { $lifetime = ($certificate->metadata()->getValidBefore() - time()) ?: 3600; - $this->shell->execute(['ssh-add', '-t', $lifetime, $privateKeyFilename], null, false, !$this->stdErr->isVerbose()); + $this->shell->execute(['ssh-add', '-t', (string) $lifetime, $privateKeyFilename], null, false, !$this->stdErr->isVerbose()); } return $certificate; @@ -169,7 +153,7 @@ private function doGenerateCertificate($forceNewKey = false) * * @return Certificate|null */ - public function getExistingCertificate() + public function getExistingCertificate(): ?Certificate { $dir = $this->config->getSessionDir(true) . DIRECTORY_SEPARATOR . 'ssh'; $private = $dir . DIRECTORY_SEPARATOR . self::PRIVATE_KEY_FILENAME; @@ -190,7 +174,7 @@ public function getExistingCertificate() * @param Certificate $certificate * @return bool */ - public function isValid(Certificate $certificate) + public function isValid(Certificate $certificate): bool { if ($certificate->hasExpired()) { return false; @@ -213,7 +197,7 @@ public function isValid(Certificate $certificate) * * @return bool */ - public function certificateConflictsWithJwt(Certificate $certificate, $jwt = null) + public function certificateConflictsWithJwt(Certificate $certificate, ?string $jwt = null): bool { $extensions = $certificate->metadata()->getExtensions(); if (!isset($extensions['access-id@platform.sh']) && !isset($extensions['access@platform.sh']) && !isset($extensions['token-claims@platform.sh'])) { @@ -265,7 +249,7 @@ public function certificateConflictsWithJwt(Certificate $certificate, $jwt = nul * @param string $filename * The private key filename. */ - private function generateSshKey($filename) + private function generateSshKey(string $filename): void { $this->stdErr->writeln('Generating local key pair', OutputInterface::VERBOSITY_VERBOSE); @@ -274,14 +258,14 @@ private function generateSshKey($filename) '-t', self::KEY_ALGORITHM, '-f', $filename, '-N', '', // No passphrase - '-C', $this->config->get('application.slug') . '-temporary-cert', // Key comment + '-C', $this->config->getStr('application.slug') . '-temporary-cert', // Key comment ]; // The "y\n" input is passed to avoid an error or prompt if ssh-keygen // encounters existing keys. This seems to be necessary during race // conditions despite deleting keys in advance with $this->fs->remove(). $this->fs->remove([$filename, $filename . '.pub']); - $this->shell->execute($args, null, true, true, [], 60, "y\n"); + $this->shell->mustExecute($args, timeout: 60, input: "y\n"); } /** @@ -290,7 +274,7 @@ private function generateSshKey($filename) * @param string $source * @param string $target */ - private function rename($source, $target) + private function rename(string $source, string $target): void { if (!\rename($source, $target)) { throw new \RuntimeException(sprintf('Failed to rename file from %s to %s', $source, $target)); diff --git a/src/Tunnel/Tunnel.php b/src/Tunnel/Tunnel.php new file mode 100644 index 0000000000..1bd9852f41 --- /dev/null +++ b/src/Tunnel/Tunnel.php @@ -0,0 +1,20 @@ + $metadata + */ + public function __construct( + public readonly string $id, + public readonly int $localPort, + public readonly string $remoteHost, + public readonly int $remotePort, + public readonly array $metadata, + public ?int $pid = null, + ) {} +} diff --git a/src/Util/Csv.php b/src/Util/Csv.php index 622eb2b2b3..864eabaf26 100644 --- a/src/Util/Csv.php +++ b/src/Util/Csv.php @@ -1,12 +1,11 @@ delimiter = $delimiter; - $this->lineBreak = $lineBreak; - } + public function __construct(private readonly string $delimiter = ',', private readonly string $lineBreak = "\r\n") {} /** * Format an array of rows as a CSV spreadsheet. * - * @param array $data + * @param array> $data * An array of rows. Each row is an array of cells (hopefully the same * number in each row). Each cell must be a string, or a type that can * be cast to a string. @@ -37,7 +32,7 @@ public function __construct($delimiter = ',', $lineBreak = "\r\n") * * @return string */ - public function format(array $data, $appendLineBreak = true) + public function format(array $data, bool $appendLineBreak = true): string { return implode($this->lineBreak, array_map([$this, 'formatRow'], $data)) . ($appendLineBreak ? $this->lineBreak : ''); @@ -46,11 +41,11 @@ public function format(array $data, $appendLineBreak = true) /** * Format an array as a CSV row. * - * @param array $data + * @param array $data * * @return string */ - private function formatRow(array $data) + private function formatRow(array $data): string { return implode($this->delimiter, array_map([$this, 'formatCell'], $data)); } @@ -58,11 +53,11 @@ private function formatRow(array $data) /** * Format a CSV cell. * - * @param string|object $cell + * @param string|\Stringable $cell * * @return string */ - protected function formatCell($cell) + protected function formatCell(string|\Stringable $cell): string { // Cast cell data to a string. $cell = (string) $cell; @@ -73,8 +68,6 @@ protected function formatCell($cell) } // Standardize line breaks. - $cell = preg_replace('/\R/u', $this->lineBreak, $cell); - - return $cell; + return preg_replace('/\R/u', $this->lineBreak, $cell); } } diff --git a/src/Util/JsonLines.php b/src/Util/JsonLines.php index 4d386bf013..bb8f5cbf2f 100644 --- a/src/Util/JsonLines.php +++ b/src/Util/JsonLines.php @@ -1,5 +1,7 @@ > */ - public static function decode($str) + public static function decode(string $str): array { $items = []; foreach (explode("\n", trim($str, "\n")) as $line) { diff --git a/src/Util/Jwt.php b/src/Util/Jwt.php index 532779f6de..7c772d5ebf 100644 --- a/src/Util/Jwt.php +++ b/src/Util/Jwt.php @@ -1,25 +1,19 @@ token = $token; - } + public function __construct(private string $token) {} /** * Returns the JWT payload claims without verification. * * @return false|array */ - public function unsafeGetUnverifiedClaims() + public function unsafeGetUnverifiedClaims(): array|false { $split = \explode('.', $this->token, 3); if (!isset($split[1])) { diff --git a/src/Util/NestedArrayUtil.php b/src/Util/NestedArrayUtil.php index f2c13e1a57..cf111deba1 100644 --- a/src/Util/NestedArrayUtil.php +++ b/src/Util/NestedArrayUtil.php @@ -1,5 +1,7 @@ $array + * @param string[] $parents + * @param bool $keyExists * * @return mixed + * @noinspection PhpMissingParamTypeInspection + * @see Copied from \Drupal\Component\Utility\NestedArray::getValue() */ - public static function &getNestedArrayValue(array &$array, array $parents, &$keyExists = false) + public static function &getNestedArrayValue(array &$array, array $parents, &$keyExists = false): mixed { $ref = &$array; foreach ($parents as $parent) { @@ -33,16 +35,14 @@ public static function &getNestedArrayValue(array &$array, array $parents, &$key } /** - * Set a nested value in an array. + * Sets a nested value in an array. * * @see Copied from \Drupal\Component\Utility\NestedArray::setValue() * - * @param array &$array - * @param array $parents - * @param mixed $value - * @param bool $force + * @param array &$array + * @param string[] $parents */ - public static function setNestedArrayValue(array &$array, array $parents, $value, $force = false) + public static function setNestedArrayValue(array &$array, array $parents, mixed $value, bool $force = false): void { $ref = &$array; foreach ($parents as $parent) { diff --git a/src/Util/OsUtil.php b/src/Util/OsUtil.php index 9fd801a103..cdb128e7c1 100644 --- a/src/Util/OsUtil.php +++ b/src/Util/OsUtil.php @@ -1,5 +1,7 @@ &|\s]/', $argument)) { @@ -84,11 +78,11 @@ public static function escapeShellArg($argument) * * @param string $name * - * @return array + * @return string[] */ - public static function findExecutables($name) + public static function findExecutables(string $name): array { - $dirs = explode(\PATH_SEPARATOR, getenv('PATH') ?: getenv('Path')); + $dirs = explode(\PATH_SEPARATOR, (string) (getenv('PATH') ?: getenv('Path'))); $suffixes = ['']; $found = []; @@ -102,8 +96,8 @@ public static function findExecutables($name) foreach ($suffixes as $suffix) { foreach ($dirs as $dir) { - if (@is_file($file = $dir.\DIRECTORY_SEPARATOR.$name.$suffix) && ($isWindows || @is_executable($file))) { - array_push($found, $file); + if (@is_file($file = $dir . \DIRECTORY_SEPARATOR . $name . $suffix) && ($isWindows || @is_executable($file))) { + $found[] = $file; } } } diff --git a/src/Util/Pager/Page.php b/src/Util/Pager/Page.php index cd2b482fac..575109da99 100644 --- a/src/Util/Pager/Page.php +++ b/src/Util/Pager/Page.php @@ -1,28 +1,31 @@ %d of %d; %d per page, %d total', - $this->pageNumber, $this->pageCount, $this->itemsPerPage, $this->total); + return \sprintf( + 'page %d of %d; %d per page, %d total', + $this->pageNumber, + $this->pageCount, + $this->itemsPerPage, + $this->total, + ); } } diff --git a/src/Util/Pager/Pager.php b/src/Util/Pager/Pager.php index fe472d647d..abf026a8d1 100644 --- a/src/Util/Pager/Pager.php +++ b/src/Util/Pager/Pager.php @@ -1,5 +1,7 @@ 65535) { return false; } - return !in_array($port, self::$unsafePorts); + return !in_array($port, self::UNSAFE_PORTS); } /** - * Check whether a port is open. - * - * @param int $port - * @param string|null $hostname - * - * @return bool + * Checks whether a port is open. */ - public static function isPortInUse($port, $hostname = null) + public static function isPortInUse(int|string $port, ?string $hostname = null): bool { - $fp = @fsockopen($hostname !== null ? $hostname : '127.0.0.1', $port, $errno, $errstr, 10); + $fp = @fsockopen($hostname !== null ? $hostname : '127.0.0.1', (int) $port, $errno, $errstr, 10); if ($fp !== false) { fclose($fp); diff --git a/src/Util/Snippeter.php b/src/Util/Snippeter.php index c0453cbf02..aa80018e5e 100644 --- a/src/Util/Snippeter.php +++ b/src/Util/Snippeter.php @@ -1,5 +1,7 @@ $b ? 1 : -1); + $value = $a <=> $b; } return $reverse ? -$value : $value; } /** * Compares domains as a sorting function. Used to sort region IDs. - * - * @param string $regionA - * @param string $regionB - * - * @return int */ - public static function compareDomains($regionA, $regionB) + public static function compareDomains(string $regionA, string $regionB): int { if (strpos($regionA, '.') && strpos($regionB, '.')) { $partsA = explode('.', $regionA, 2); @@ -49,13 +40,9 @@ public static function compareDomains($regionA, $regionB) * * Array keys will be preserved. * - * @param object[] $objects - * @param string $property - * @param bool $reverse - * - * @return void + * @param object[] &$objects */ - public static function sortObjects(array &$objects, $property, $reverse = false) + public static function sortObjects(array &$objects, string $property, bool $reverse = false): void { uasort($objects, function ($a, $b) use ($property, $reverse) { if (!property_exists($a, $property) || !property_exists($b, $property)) { diff --git a/src/Util/SslUtil.php b/src/Util/SslUtil.php index 25852c35a9..4eb27bda75 100644 --- a/src/Util/SslUtil.php +++ b/src/Util/SslUtil.php @@ -1,5 +1,7 @@ $before . $i . $after, $items); if (count($items) === 1) { return reset($items); } diff --git a/src/Util/TimezoneUtil.php b/src/Util/TimezoneUtil.php index 1e469ceb57..0eb9b7d686 100644 --- a/src/Util/TimezoneUtil.php +++ b/src/Util/TimezoneUtil.php @@ -1,5 +1,7 @@ 1 && $tz[0] === ':') { $filename = substr($tz, 1); @@ -82,18 +84,17 @@ private static function convertTz($tz) * * @return string|false */ - private static function detectSystemTimezone() + private static function detectSystemTimezone(): string|false { // Mac OS X (and older Linuxes): /etc/localtime is a symlink to the // timezone in /usr/share/zoneinfo or /var/db/timezone/zoneinfo. - if (is_link('/etc/localtime')) { - $filename = readlink('/etc/localtime'); + if (is_link('/etc/localtime') && ($filename = readlink('/etc/localtime'))) { $prefixes = [ '/usr/share/zoneinfo/', '/var/db/timezone/zoneinfo/', ]; foreach ($prefixes as $prefix) { - if (strpos($filename, $prefix) === 0) { + if (str_starts_with($filename, $prefix)) { return substr($filename, strlen($prefix)); } } @@ -111,7 +112,7 @@ private static function detectSystemTimezone() if (file_exists('/etc/sysconfig/clock')) { $data = parse_ini_file('/etc/sysconfig/clock'); if (!empty($data['ZONE'])) { - return trim($data['ZONE']); + return trim((string) $data['ZONE']); } } diff --git a/src/Util/VersionUtil.php b/src/Util/VersionUtil.php index b5d0263c6b..ff074df6d6 100644 --- a/src/Util/VersionUtil.php +++ b/src/Util/VersionUtil.php @@ -1,5 +1,7 @@ */ - public function parseFile($filename) + public function parseFile(string $filename): TaggedValue|string|array { return $this->parseContent($this->readFile($filename), $filename); } @@ -35,18 +35,18 @@ public function parseFile($filename) * @param string $filename The filename where the content originated. This * is required for formatting useful error messages. * - * @throws \Platformsh\Cli\Exception\InvalidConfigException if the config is invalid - * @throws ParseException if the config could not be parsed + * @return TaggedValue|string|array * - * @return array|string|TaggedValue + * @throws ParseException if the config could not be parsed + * @throws InvalidConfigException if the config is invalid */ - public function parseContent($content, $filename) + public function parseContent(string $content, string $filename): TaggedValue|string|array { $content = $this->cleanUp($content); try { $parsed = (new Yaml())->parse($content, Yaml::PARSE_CUSTOM_TAGS); } catch (ParseException $e) { - throw new ParseException($e->getMessage(), $e->getParsedLine(), $e->getSnippet(), $filename, $e->getPrevious()); + throw new InvalidConfigException($e->getMessage(), $filename, '', $e); } return $this->processTags($parsed, $filename); @@ -54,23 +54,23 @@ public function parseContent($content, $filename) /** * Cleans up YAML to conform to the Symfony parser's expectations. - * - * @param string $content - * - * @return string */ - private function cleanUp($content) + private function cleanUp(string $content): string { // If an entire file or snippet is indented, remove the indent. - if (substr(ltrim($content, "\r\n"), 0, 1) === ' ') { + $trimmed = ltrim($content, "\r\n"); + if (strlen($trimmed) > 0 && ($trimmed[0] === "\t" || $trimmed[0] === ' ')) { $lines = preg_split('/\n|\r|\r\n/', $content); + if (!$lines) { + throw new \RuntimeException('Failed to split content by lines'); + } $indents = []; foreach ($lines as $line) { // Ignore blank lines. if (trim($line) === '') { continue; } - $indents[] = strlen($line) - strlen(ltrim($line, ' ')); + $indents[] = strlen($line) - strlen(ltrim($line, "\t ")); } if (!empty($indents[0]) && $indents[0] === min($indents)) { foreach ($lines as &$line) { @@ -86,13 +86,9 @@ private function cleanUp($content) /** * Reads a file and throws appropriate exceptions on failure. * - * @param string $filename - * * @throws \RuntimeException if the file cannot be found or read. - * - * @return string */ - private function readFile($filename) + private function readFile(string $filename): string { if (!file_exists($filename)) { throw new \RuntimeException(sprintf('File not found: %s', $filename)); @@ -105,25 +101,18 @@ private function readFile($filename) } /** - * Processes custom tags in the parsed config. + * Recursively processes custom tags in the parsed config. * - * @param array $config - * @param string $filename - * - * @throws \Platformsh\Cli\Exception\InvalidConfigException - * - * @return array + * @param TaggedValue|array $config */ - private function processTags($config, $filename) + private function processTags(mixed $config, string $filename): mixed { - if (!is_array($config)) { + if ($config instanceof TaggedValue) { return $this->processSingleTag($config, $filename); } - foreach ($config as $key => $item) { - if (is_array($item)) { + if (is_array($config)) { + foreach ($config as $key => $item) { $config[$key] = $this->processTags($item, $filename); - } else { - $config[$key] = $this->processSingleTag($item, $filename, $key); } } @@ -133,49 +122,33 @@ private function processTags($config, $filename) /** * Processes a single config item, which may be a custom tag. * - * @param mixed $item + * @param TaggedValue $item * @param string $filename * @param string $configKey * - * @return mixed + * @return TaggedValue|string|array|array */ - private function processSingleTag($item, $filename, $configKey = '') + private function processSingleTag(TaggedValue $item, string $filename, string $configKey = ''): TaggedValue|string|array { - if ($item instanceof TaggedValue) { - $tag = $item->getTag(); - $value = $item->getValue(); - } elseif (is_string($item) && strlen($item) && $item[0] === '!' && preg_match('/\!([a-z]+)[ \t]+(.+)$/i', $item, $matches)) { - $tag = $matches[1]; - $value = Yaml::parse($matches[2]); - if (!is_string($value)) { - return $item; - } - } else { - return $item; - } + $tag = $item->getTag(); + $value = $item->getValue(); // Process the '!include' tag. The '!archive' and '!file' tags are // ignored as they are not relevant to the CLI (yet). - switch ($tag) { - case 'include': - return $this->resolveInclude($value, $filename, $configKey); - } - - return $item; + return match ($tag) { + 'include' => $this->resolveInclude($value, $filename, $configKey), + default => $item, + }; } /** - * Resolve an !include config tag value. + * Resolves an !include config tag value. * - * @param mixed $value - * @param string $filename - * @param string $configKey + * @throws InvalidConfigException * - * @throws \Platformsh\Cli\Exception\InvalidConfigException - * - * @return string|array + * @return TaggedValue|string|array */ - private function resolveInclude($value, $filename, $configKey = '') + private function resolveInclude(mixed $value, string $filename, string $configKey = ''): TaggedValue|string|array { if (is_string($value)) { $includeType = 'yaml'; @@ -193,27 +166,18 @@ private function resolveInclude($value, $filename, $configKey = '') if (!$realDir = realpath($dir)) { throw new \RuntimeException('Failed to resolve directory: ' . $dir); } - $includeFile = rtrim($realDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . ltrim($includePath, DIRECTORY_SEPARATOR); + $includeFile = rtrim($realDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . ltrim((string) $includePath, DIRECTORY_SEPARATOR); try { - switch ($includeType) { - // Ignore binary and archive values (for now at least). - case 'archive': - case 'binary': - return $value; - - case 'yaml': - return $this->parseFile($includeFile); - - case 'string': - return $this->readFile($includeFile); - - default: - throw new InvalidConfigException(sprintf( - 'Unrecognized !include tag type "%s"', - $includeType - ), $filename, $configKey); - } + return match ($includeType) { + 'archive', 'binary' => $value, + 'yaml' => $this->parseFile($includeFile), + 'string' => $this->readFile($includeFile), + default => throw new InvalidConfigException(sprintf( + 'Unrecognized !include tag type "%s"', + $includeType, + ), $filename, $configKey), + }; } catch (\Exception $e) { if ($e instanceof InvalidConfigException) { throw $e; diff --git a/stub.php b/stub.php deleted file mode 100644 index 28ef4ceb6f..0000000000 --- a/stub.php +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env php - diff --git a/tests/Command/App/AppConfigGetTest.php b/tests/Command/App/AppConfigGetTest.php index b97cedf29e..1e328f866b 100644 --- a/tests/Command/App/AppConfigGetTest.php +++ b/tests/Command/App/AppConfigGetTest.php @@ -1,27 +1,20 @@ setInteractive(false); - (new AppConfigGetCommand()) - ->run($input, $output); - - return $output->fetch(); - } - - public function testGetConfig() { - $app = base64_encode(json_encode([ + public function testGetConfig(): void + { + $app = base64_encode((string) json_encode([ 'type' => 'php:7.3', 'name' => 'app', 'disk' => 512, @@ -31,21 +24,21 @@ public function testGetConfig() { putenv('PLATFORM_APPLICATION=' . $app); $this->assertEquals( 'app', - (new Parser)->parse($this->runCommand([ + (new Parser())->parse(MockApp::runAndReturnOutput('app:config', [ '--property' => 'name', - ])) + ])), ); $this->assertEquals( [], - (new Parser)->parse($this->runCommand([ + (new Parser())->parse(MockApp::runAndReturnOutput('app:config', [ '--property' => 'mounts', - ])) + ])), ); $this->assertEquals( '', - (new Parser)->parse($this->runCommand([ + (new Parser())->parse(MockApp::runAndReturnOutput('app:config', [ '--property' => 'blank', - ])) + ])), ); putenv('PLATFORM_APPLICATION='); } diff --git a/tests/Command/DecodeTest.php b/tests/Command/DecodeTest.php index ff598bf72c..f8d57a3906 100644 --- a/tests/Command/DecodeTest.php +++ b/tests/Command/DecodeTest.php @@ -1,62 +1,57 @@ setInteractive(false); - (new DecodeCommand())->run($input, $output); - - return $output->fetch(); - } - - public function testDecode() { - $var = base64_encode(json_encode([ + public function testDecode(): void + { + $var = base64_encode((string) json_encode([ 'foo' => 'bar', 'fee' => 'bor', 'nest' => ['nested' => 'baz'], ])); $this->assertEquals( 'bar', - rtrim($this->runCommand([ + rtrim(MockApp::runAndReturnOutput('decode', [ 'value' => $var, '--property' => 'foo', - ]), "\n") + ]), "\n"), ); $this->assertEquals( 'baz', - rtrim($this->runCommand([ + rtrim(MockApp::runAndReturnOutput('decode', [ 'value' => $var, '--property' => 'nest.nested', - ]), "\n") + ]), "\n"), ); } - public function testDecodeEmptyObject() { + public function testDecodeEmptyObject(): void + { $this->assertEquals( '{}', - rtrim($this->runCommand([ - 'value' => base64_encode(json_encode(new \stdClass())) - ]), "\n") + rtrim(MockApp::runAndReturnOutput('decode', [ + 'value' => base64_encode((string) json_encode(new \stdClass())), + ]), "\n"), ); - $this->assertEquals( - 'Property not found: nonexistent', - rtrim($this->runCommand([ - 'value' => base64_encode(json_encode(new \stdClass())), - '--property' => 'nonexistent' - ]), "\n") - ); + try { + $this->assertEquals( + 'Property not found: nonexistent', + rtrim(MockApp::runAndReturnOutput('decode', [ + 'value' => base64_encode((string) json_encode(new \stdClass())), + '--property' => 'nonexistent', + ]), "\n"), + ); + } catch (\RuntimeException) { + } } } diff --git a/tests/Command/Environment/EnvironmentRelationshipsTest.php b/tests/Command/Environment/EnvironmentRelationshipsTest.php index 1bcbb22f50..bf6ca82e6f 100644 --- a/tests/Command/Environment/EnvironmentRelationshipsTest.php +++ b/tests/Command/Environment/EnvironmentRelationshipsTest.php @@ -1,20 +1,19 @@ [ 0 => [ 'host' => 'database.internal', @@ -26,7 +25,7 @@ public function setUp(): void 'rel' => 'mysql', 'service' => 'database', 'query' => ['is_master' => true], - ] + ], ], ])); putenv('PLATFORM_RELATIONSHIPS=' . $mockRelationships); @@ -37,31 +36,23 @@ public function tearDown(): void putenv('PLATFORM_RELATIONSHIPS='); } - private function runCommand(array $args) { - $output = new BufferedOutput(); - $input = new ArrayInput($args); - $input->setInteractive(false); - (new EnvironmentRelationshipsCommand()) - ->run($input, $output); - - return $output->fetch(); - } - - public function testGetRelationshipHost() { + public function testGetRelationshipHost(): void + { $this->assertEquals( 'database.internal', - rtrim($this->runCommand([ + rtrim(MockApp::runAndReturnOutput('rel', [ '--property' => 'database.0.host', - ]), "\n") + ]), "\n"), ); } - public function testGetRelationshipUrl() { + public function testGetRelationshipUrl(): void + { $this->assertEquals( 'mysql://main:123@database.internal:3306/main', - rtrim($this->runCommand([ + rtrim(MockApp::runAndReturnOutput('rel', [ '--property' => 'database.0.url', - ]), "\n") + ]), "\n"), ); } } diff --git a/tests/Command/Environment/EnvironmentUrlTest.php b/tests/Command/Environment/EnvironmentUrlTest.php index 07ffb0c3d1..332c9097e2 100644 --- a/tests/Command/Environment/EnvironmentUrlTest.php +++ b/tests/Command/Environment/EnvironmentUrlTest.php @@ -1,21 +1,19 @@ [ 'primary' => true, 'type' => 'upstream', @@ -36,51 +34,25 @@ public function tearDown(): void putenv('PLATFORM_ROUTES='); } - private function runCommand(array $args, $verbosity = OutputInterface::VERBOSITY_NORMAL) { - $output = new BufferedOutput(); - $output->setVerbosity($verbosity); - $input = new ArrayInput($args); - $input->setInteractive(false); - (new EnvironmentUrlCommand())->run($input, $output); - - return $output->fetch(); - } - - public function testUrl() { + public function testUrl(): void + { $this->assertEquals( "https://example.com\n" . "http://example.com\n", - $this->runCommand([ + MockApp::runAndReturnOutput('env:url', [ '--pipe' => true, - ]) + ]), ); } - public function testPrimaryUrl() { + public function testPrimaryUrl(): void + { $this->assertEquals( 'https://example.com', - rtrim($this->runCommand([ + rtrim(MockApp::runAndReturnOutput('env:url', [ '--primary' => true, '--browser' => '0', - ]), "\n") + ]), "\n"), ); } - - public function testNonExistentBrowserIsNotFound() { - putenv('DISPLAY=fake'); - $result = $this->runCommand([ - '--browser' => 'nonexistent', - ]); - $this->assertStringContainsString('Command not found: nonexistent', $result); - $this->assertStringContainsString("https://example.com\n", $result); - - $display = getenv('DISPLAY'); - putenv('DISPLAY=none'); - $result = $this->runCommand([ - '--browser' => 'nonexistent', - ], OutputInterface::VERBOSITY_DEBUG); - $this->assertStringContainsString('no display found', $result); - $this->assertStringContainsString("https://example.com\n", $result); - putenv('DISPLAY=' . $display); - } } diff --git a/tests/Command/Route/RouteGetTest.php b/tests/Command/Route/RouteGetTest.php index 467e2f93a8..365d8e03de 100644 --- a/tests/Command/Route/RouteGetTest.php +++ b/tests/Command/Route/RouteGetTest.php @@ -1,20 +1,19 @@ [ 'primary' => true, 'type' => 'upstream', @@ -35,39 +34,32 @@ public function tearDown(): void putenv('PLATFORM_ROUTES='); } - private function runCommand(array $args) { - $output = new BufferedOutput(); - $input = new ArrayInput($args); - $input->setInteractive(false); - (new RouteGetCommand())->run($input, $output); - - return $output->fetch(); - } - - public function testGetPrimaryRouteUrl() { + public function testGetPrimaryRouteUrl(): void + { $this->assertEquals( 'https://example.com', - rtrim($this->runCommand([ + rtrim(MockApp::runAndReturnOutput('route:get', [ '--primary' => true, '--property' => 'url', - ]), "\n") + ]), "\n"), ); } - public function testGetRouteByOriginalUrl() { + public function testGetRouteByOriginalUrl(): void + { $this->assertEquals( 'false', - rtrim($this->runCommand([ + rtrim(MockApp::runAndReturnOutput('route:get', [ 'route' => 'http://{default}', '--property' => 'primary', - ]), "\n") + ]), "\n"), ); $this->assertEquals( 'true', - rtrim($this->runCommand([ + rtrim(MockApp::runAndReturnOutput('route:get', [ 'route' => 'https://{default}', '--property' => 'primary', - ]), "\n") + ]), "\n"), ); } } diff --git a/tests/Command/Route/RouteListTest.php b/tests/Command/Route/RouteListTest.php index c165fc8be4..dfa5c2df38 100644 --- a/tests/Command/Route/RouteListTest.php +++ b/tests/Command/Route/RouteListTest.php @@ -1,20 +1,19 @@ [ 'type' => 'redirect', 'to' => 'https://{default}', @@ -35,24 +34,16 @@ public function tearDown(): void putenv('PLATFORM_ROUTES='); } - private function runCommand(array $args) { - $output = new BufferedOutput(); - $input = new ArrayInput($args); - $input->setInteractive(false); - (new RouteListCommand())->run($input, $output); - - return $output->fetch(); - } - - public function testListRoutes() { + public function testListRoutes(): void + { $this->assertEquals( "https://{default}\tupstream\tapp:http\n" . "http://{default}\tredirect\thttps://{default}\n", - $this->runCommand([ + MockApp::runAndReturnOutput('routes', [ '--format' => 'tsv', '--columns' => ['route,type,to'], '--no-header' => true, - ]) + ]), ); } } diff --git a/tests/Command/User/UserAddCommandTest.php b/tests/Command/User/UserAddCommandTest.php index c9285f2acb..78162055c3 100644 --- a/tests/Command/User/UserAddCommandTest.php +++ b/tests/Command/User/UserAddCommandTest.php @@ -1,19 +1,24 @@ */ + private array $mockTypes = []; protected function setUp(): void { @@ -40,10 +45,21 @@ protected function setUp(): void } } - public function testGetSpecifiedEnvironmentRoles() + private function getCommandInstance(): UserAddCommand + { + $app = MockApp::instance(); + $command = $app->find('user:add'); + if ($command instanceof LazyCommand) { + $command = $command->getCommand(); + } + /** @var UserAddCommand $command */ + return $command; + } + + public function testGetSpecifiedEnvironmentRoles(): void { // Set up a mock command to make the private method accessible. - $command = new UserAddCommand(); + $command = $this->getCommandInstance(); $m = new \ReflectionMethod($command, 'getSpecifiedEnvironmentRoles'); $m->setAccessible(true); @@ -70,10 +86,13 @@ public function testGetSpecifiedEnvironmentRoles() ], ]; foreach ($cases as $i => $case) { - list($args, $expectedRoles) = $case; - $errorMessage = isset($case[2]) ? $case[2] : ''; + [$args, $expectedRoles] = $case; + $errorMessage = $case[2] ?? ''; try { $result = $m->invoke($command, $args, $this->mockEnvironments); + if ($errorMessage !== '') { + $this->fail('No exception thrown'); + } $this->assertEquals($expectedRoles, $result, "case $i roles"); $this->assertEquals('', $errorMessage, "case $i error message"); } catch (\InvalidArgumentException $e) { @@ -82,49 +101,43 @@ public function testGetSpecifiedEnvironmentRoles() } } -// public function testGetSpecifiedTypeRoles() -// { -// // Set up a mock command to make the private method accessible. -// $command = new UserAddCommand(); -// // Fake running the command to set I/O properties. -// $cwd = getcwd(); -// chdir('/tmp'); -// try { $command->run(new ArrayInput([]), new NullOutput()); } catch (\RuntimeException $e) {} -// chdir($cwd); -// $m = new \ReflectionMethod($command, 'getSpecifiedTypeRoles'); -// $m->setAccessible(true); -// -// // Test cases: quintuples of role arguments, the output, whether to ignore errors, the error message if any, the remaining roles if any. -// // TODO convert to anonymous class in PHP 7 -// $cases = [ -// [ -// ['staging:viewer', 'stg:admin', 'viewer'], -// ['staging' => 'viewer'], -// ['stg:admin', 'viewer'], -// ], -// [ -// ['development:viewer', 'nonexistent:viewer', 'stg:admin'], -// ['development' => 'viewer'], -// ['nonexistent:viewer', 'stg:admin'], -// ], -// [ -// ['dev%:v', 'prod:admin'], -// ['development' => 'viewer'], -// ['prod:admin'], -// ], -// ]; -// foreach ($cases as $i => $case) { -// list($roles, $expectedRoles, $expectedRemainingRoles) = $case; -// $result = $m->invokeArgs($command, [&$roles, $this->mockTypes]); -// $this->assertEquals($expectedRoles, $result, "case $i roles"); -// $this->assertEquals($expectedRemainingRoles, array_values($roles), "case $i remaining roles"); -// } -// } + public function testGetSpecifiedTypeRoles(): void + { + $command = $this->getCommandInstance(); + $m = new \ReflectionMethod($command, 'getSpecifiedTypeRoles'); + $m->setAccessible(true); + + // Test cases: quintuples of role arguments, the output, whether to ignore errors, the error message if any, the remaining roles if any. + // TODO convert to anonymous class in PHP 7 + $cases = [ + [ + ['staging:viewer', 'stg:admin', 'viewer'], + ['staging' => 'viewer'], + ['stg:admin', 'viewer'], + ], + [ + ['development:viewer', 'nonexistent:viewer', 'stg:admin'], + ['development' => 'viewer'], + ['nonexistent:viewer', 'stg:admin'], + ], + [ + ['dev%:v', 'prod:admin'], + ['development' => 'viewer'], + ['prod:admin'], + ], + ]; + foreach ($cases as $i => $case) { + [$roles, $expectedRoles, $expectedRemainingRoles] = $case; + $result = $m->invokeArgs($command, [&$roles, $this->mockTypes]); + $this->assertEquals($expectedRoles, $result, "case $i roles"); + $this->assertEquals($expectedRemainingRoles, array_values($roles), "case $i remaining roles"); + } + } - public function testConvertEnvironmentRolesToTypeRoles() + public function testConvertEnvironmentRolesToTypeRoles(): void { // Set up a mock command to make the private method accessible. - $command = new UserAddCommand(); + $command = $this->getCommandInstance(); $m = new \ReflectionMethod($command, 'convertEnvironmentRolesToTypeRoles'); $m->setAccessible(true); @@ -158,7 +171,7 @@ public function testConvertEnvironmentRolesToTypeRoles() ]; $output = new NullOutput(); foreach ($cases as $i => $case) { - list($environmentRoles, $typeRoles, $expectedTypeRoles) = $case; + [$environmentRoles, $typeRoles, $expectedTypeRoles] = $case; $result = $m->invoke($command, $environmentRoles, $typeRoles, $this->mockEnvironments, $output); $this->assertEquals($expectedTypeRoles, $result, "case $i"); } diff --git a/tests/Command/WelcomeCommandTest.php b/tests/Command/WelcomeCommandTest.php index 25e34db759..1af39d19b9 100644 --- a/tests/Command/WelcomeCommandTest.php +++ b/tests/Command/WelcomeCommandTest.php @@ -1,38 +1,31 @@ setInteractive(false); - (new WelcomeCommand()) - ->run($input, $output); - - return $output->fetch(); - } - - public function testWelcomeOnLocalContainer() { + public function testWelcomeOnLocalContainer(): void + { chdir('/'); putenv('PLATFORM_PROJECT=test-project'); putenv('PLATFORM_BRANCH=test-environment'); - putenv('PLATFORM_ROUTES=' . base64_encode(json_encode([]))); + putenv('PLATFORM_ROUTES=' . base64_encode((string) json_encode([]))); putenv('PLATFORMSH_CLI_SESSION_ID=test' . rand(100, 999)); - $result = $this->runCommand([]); + $result = MockApp::runAndReturnOutput('welcome'); $this->assertStringContainsString( 'Project ID: test-project', - $result + $result, ); $this->assertStringContainsString( 'Local environment commands', - $result + $result, ); } } diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php index adb8743a02..8427112ddd 100644 --- a/tests/ConfigTest.php +++ b/tests/ConfigTest.php @@ -1,5 +1,7 @@ configFile); $this->assertTrue($config->has('application.name')); $this->assertFalse($config->has('nonexistent')); - $this->assertEquals('Mock CLI', $config->get('application.name')); + $this->assertEquals('Mock CLI', $config->getStr('application.name')); $this->assertEquals(123, $config->getWithDefault('nonexistent', 123)); } - public function testGetHomeDirectory() + public function testGetHomeDirectory(): void { $homeDir = (new Config(['HOME' => '.'], $this->configFile))->getHomeDirectory(); $this->assertNotEmpty($homeDir, 'Home directory returned'); $this->assertNotEquals('.', $homeDir, 'Home directory not relative'); - $homeDir = (new Config(['MOCK_CLI_HOME' => __DIR__ . '/data', 'HOME' => __DIR__], $this->configFile))->getHomeDirectory(); + $homeDir = (new Config(['MOCK_CLI_HOME' => __DIR__ . '/data', 'HOME' => __DIR__], $this->configFile))->getHomeDirectory(); $this->assertEquals(__DIR__ . '/data', $homeDir, 'Home directory overridden'); - $homeDir = (new Config(['MOCK_CLI_HOME' => '', 'HOME' => __DIR__], $this->configFile))->getHomeDirectory(); + $homeDir = (new Config(['MOCK_CLI_HOME' => '', 'HOME' => __DIR__], $this->configFile))->getHomeDirectory(); $this->assertEquals(__DIR__, $homeDir, 'Empty value treated as nonexistent'); } /** * Test that selected environment variables can override initial config. */ - public function testEnvironmentOverrides() + public function testEnvironmentOverrides(): void { - $config = new Config([], $this->configFile); + new Config([], $this->configFile); putenv('MOCK_CLI_DISABLE_CACHE=0'); $config = new Config([ 'MOCK_CLI_APPLICATION_NAME' => 'Overridden application name', - 'MOCK_CLI_DEBUG' => 1, + 'MOCK_CLI_DEBUG' => '1', ], $this->configFile); $this->assertFalse((bool) $config->get('api.disable_cache')); $this->assertTrue((bool) $config->get('api.debug')); @@ -58,7 +60,7 @@ public function testEnvironmentOverrides() /** * Test that selected user config can override initial config. */ - public function testUserConfigOverrides() + public function testUserConfigOverrides(): void { $config = new Config([], $this->configFile); $this->assertFalse($config->has('experimental.test')); @@ -73,7 +75,7 @@ public function testUserConfigOverrides() /** * Test misc. dynamic defaults. */ - public function testDynamicDefaults() + public function testDynamicDefaults(): void { $config = new Config([], $this->configFile); $this->assertEquals('mock-cli', $config->get('application.slug')); @@ -87,7 +89,7 @@ public function testDynamicDefaults() /** * Test dynamic defaults for URLs. */ - public function testDynamicUrlDefaults() + public function testDynamicUrlDefaults(): void { $config = new Config(['MOCK_CLI_AUTH_URL' => 'https://auth.example.com'], $this->configFile); $this->assertEquals('https://auth.example.com/oauth2/token', $config->get('api.oauth2_token_url')); @@ -98,7 +100,7 @@ public function testDynamicUrlDefaults() /** * Test dynamic defaults for local paths. */ - public function testLocalPathDefaults() + public function testLocalPathDefaults(): void { $config = new Config([], $this->configFile); $this->assertEquals('.mock/local', $config->get('local.local_dir')); @@ -115,7 +117,7 @@ public function testLocalPathDefaults() /** * Test the default for application.writable_user_dir */ - public function testGetWritableUserDir() + public function testGetWritableUserDir(): void { $config = new Config([], $this->configFile); $this->assertEquals('mock-cli-user-config', $config->get('application.user_config_dir')); diff --git a/tests/Console/AdaptiveTableTest.php b/tests/Console/AdaptiveTableTest.php index 5e8024ae02..b25590415a 100644 --- a/tests/Console/AdaptiveTableTest.php +++ b/tests/Console/AdaptiveTableTest.php @@ -1,5 +1,7 @@ assertLessThanOrEqual($maxTableWidth, max($lineWidths)); $expected = <<<'EOT' -+-----+------------+------------+------------+----------+ -| Row | Lorem | ipsum | dolor | sit | -+-----+------------+------------+------------+----------+ -| #1 | amet | consectetu | adipiscing | Quisque | -| | | r | elit | pulvinar | -| #2 | tellus sit | sollicitud | tincidunt | risus | -| | amet | in | | | -+-----+------------+------------+------------+----------+ -| #3 | risus | sem | mattis | ex | -| #4 | quis | luctus | lorem | ligula | -| | | metus | cursus | | -+-----+------------+------------+------------+----------+ - -EOT; + +-----+------------+------------+------------+----------+ + | Row | Lorem | ipsum | dolor | sit | + +-----+------------+------------+------------+----------+ + | #1 | amet | consectetu | adipiscing | Quisque | + | | | r | elit | pulvinar | + | #2 | tellus sit | sollicitud | tincidunt | risus | + | | amet | in | | | + +-----+------------+------------+------------+----------+ + | #3 | risus | sem | mattis | ex | + | #4 | quis | luctus | lorem | ligula | + | | | metus | cursus | | + +-----+------------+------------+------------+----------+ + + EOT; $this->assertEquals($expected, $result); } /** * Test that the left-indent of cells is preserved. */ - public function testAdaptedRowsWithIndent() + public function testAdaptedRowsWithIndent(): void { $maxTableWidth = 75; $buffer = new BufferedOutput(); @@ -86,27 +88,27 @@ public function testAdaptedRowsWithIndent() $this->assertLessThanOrEqual($maxTableWidth, max($lineWidths)); $expected = <<<'EOT' -+-----+------------+-------------+--------------+------------+ -| Row | Lorem | ipsum | dolor | Indented | -+-----+------------+-------------+--------------+------------+ -| #1 | amet | consectetur | adipiscing | Quisque | -| | | | elit | pulvinar | -| #2 | tellus sit | sollicitudi | tincidunt | risus | -| | amet | n | | | -+-----+------------+-------------+--------------+------------+ -| #3 | risus | sem | mattis | ex | -| #4 | quis | luctus | lorem cursus | ligula | -| | | metus | | | -+-----+------------+-------------+--------------+------------+ - -EOT; + +-----+------------+-------------+--------------+------------+ + | Row | Lorem | ipsum | dolor | Indented | + +-----+------------+-------------+--------------+------------+ + | #1 | amet | consectetur | adipiscing | Quisque | + | | | | elit | pulvinar | + | #2 | tellus sit | sollicitudi | tincidunt | risus | + | | amet | n | | | + +-----+------------+-------------+--------------+------------+ + | #3 | risus | sem | mattis | ex | + | #4 | quis | luctus | lorem cursus | ligula | + | | | metus | | | + +-----+------------+-------------+--------------+------------+ + + EOT; $this->assertEquals($expected, $result); } /** * Test a non-wrapping table cell. */ - public function testAdaptedRowsWithNonWrappingCell() + public function testAdaptedRowsWithNonWrappingCell(): void { $maxTableWidth = 60; $buffer = new BufferedOutput(); @@ -124,19 +126,19 @@ public function testAdaptedRowsWithNonWrappingCell() $result = $buffer->fetch(); $expected = <<<'EOT' -+-----+------------+--------------+------------+----------+ -| Row | Lorem | ipsum | dolor | sit | -+-----+------------+--------------+------------+----------+ -| #1 | amet | consectetur | adipiscing | Quisque | -| | | | elit | pulvinar | -| #2 | tellus sit | sollicitudin | tincidunt | risus | -| | amet | | | | -| #3 | risus | sem | mattis | ex | -| #4 | quis | luctus metus | lorem | ligula | -| | | | cursus | | -+-----+------------+--------------+------------+----------+ - -EOT; + +-----+------------+--------------+------------+----------+ + | Row | Lorem | ipsum | dolor | sit | + +-----+------------+--------------+------------+----------+ + | #1 | amet | consectetur | adipiscing | Quisque | + | | | | elit | pulvinar | + | #2 | tellus sit | sollicitudin | tincidunt | risus | + | | amet | | | | + | #3 | risus | sem | mattis | ex | + | #4 | quis | luctus metus | lorem | ligula | + | | | | cursus | | + +-----+------------+--------------+------------+----------+ + + EOT; $this->assertEquals($expected, $result); } @@ -146,7 +148,7 @@ public function testAdaptedRowsWithNonWrappingCell() * @param string $input * @param int[] $maxLengths */ - private function assertWrappedWithDecoration($input, array $maxLengths = [5, 8, 13, 21, 34, 55, 89]) + private function assertWrappedWithDecoration(string $input, array $maxLengths = [5, 8, 13, 21, 34, 55, 89]): void { $o = new BufferedOutput(); $f = $o->getFormatter(); @@ -159,31 +161,31 @@ private function assertWrappedWithDecoration($input, array $maxLengths = [5, 8, } } - public function testWrapWithDecorationPlain() + public function testWrapWithDecorationPlain(): void { $this->assertWrappedWithDecoration( - 'This is a test of raw text which should be wrapped as normal.' + 'This is a test of raw text which should be wrapped as normal.', ); } - public function testWrapWithDecorationSimple() + public function testWrapWithDecorationSimple(): void { $this->assertWrappedWithDecoration( - 'The quick brown fox jumps over the lazy dog.' + 'The quick brown fox jumps over the lazy dog.', ); } - public function testWrapWithDecorationComplex() + public function testWrapWithDecorationComplex(): void { $this->assertWrappedWithDecoration( - 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat (cupidatat) non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.' + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat (cupidatat) non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.', ); } - public function testWrapWithDecorationIncludingEscapedTags() + public function testWrapWithDecorationIncludingEscapedTags(): void { $this->assertWrappedWithDecoration( - 'The quick brown fox jumps over the lazy \\.' + 'The quick brown fox jumps over the lazy \\.', ); } } diff --git a/tests/Container.php b/tests/Container.php index 2947ea75ad..40fb13c1c5 100644 --- a/tests/Container.php +++ b/tests/Container.php @@ -1,5 +1,7 @@ load(CLI_ROOT . '/services.yaml'); + $loader->load(__DIR__ . '/services_test.yaml'); + $container->compile(); return $container; } } diff --git a/tests/CredentialHelper/CredentialHelperTest.php b/tests/CredentialHelper/CredentialHelperTest.php index 2ff150ca2a..4efe10909d 100644 --- a/tests/CredentialHelper/CredentialHelperTest.php +++ b/tests/CredentialHelper/CredentialHelperTest.php @@ -1,5 +1,7 @@ storage->deleteAll(); } - public function testCredentialStorage() + public function testCredentialStorage(): void { if (!$this->manager->isSupported()) { $this->markTestIncomplete('Skipping credential helper test (not supported on this system)'); - return; } $this->manager->install(); // Set up the session. - $testData = ['foo' => 'bar', '1' => ['2' => '3']]; + $testData = ['foo' => 'bar']; $session = new Session(); $session->setStorage($this->storage); // Save data. - $session->setData($testData); + foreach ($testData as $k => $v) { + $session->set($k, $v); + } $session->save(); // Reset the session, reload from the credential helper, and check session data. - $session->load(true); - $this->assertEquals($testData, $session->getData()); + $session = new Session(); + $session->setStorage($this->storage); + foreach ($testData as $k => $v) { + $this->assertEquals($v, $session->get($k)); + } // Clear and reset the session, and check the session is empty. $session->clear(); $session->save(); - $session->load(true); - $this->assertEquals([], $session->getData()); + $session = new Session(); + $session->setStorage($this->storage); + foreach ($testData as $k => $v) { + $this->assertEquals(null, $session->get($k)); + } // Write to the session again, and check deleteAllSessions() works. - $session->setData($testData); + $session->set('some key', 'some value'); $session->save(); - $session->load(true); - $this->assertNotEmpty($session->getData()); + $session = new Session(); + $session->setStorage($this->storage); + $this->assertEquals('some value', $session->get('some key')); $this->storage->deleteAll(); - $session->load(true); - $this->assertEmpty($session->getData()); + $session = new Session(); + $session->setStorage($this->storage); + $this->assertEquals(null, $session->get('some key')); } } diff --git a/tests/HasTempDirTrait.php b/tests/HasTempDirTrait.php index d1f86c527e..7e736d38ce 100644 --- a/tests/HasTempDirTrait.php +++ b/tests/HasTempDirTrait.php @@ -1,12 +1,14 @@ tempDir)) { $this->tempDir = $this->createTempDir(sys_get_temp_dir(), 'pshCliTmp'); @@ -19,11 +21,11 @@ protected function tempDirSetUp() * * @return string */ - protected function createTempDir($parentDir, $prefix = '') + protected function createTempDir(string $parentDir, string $prefix = ''): string { if (!($tempDir = tempnam($parentDir, $prefix)) || !unlink($tempDir) - || !mkdir($tempDir, 0755)) { + || !mkdir($tempDir, 0o755)) { throw new \RuntimeException('Failed to create temporary directory in: ' . $parentDir); } @@ -35,7 +37,7 @@ protected function createTempDir($parentDir, $prefix = '') * * @return string */ - protected function createTempSubDir($prefix = '') + protected function createTempSubDir(string $prefix = ''): string { $this->tempDirSetUp(); diff --git a/tests/InstallerTest.php b/tests/InstallerTest.php index 9e0b0b9110..9f0125948b 100644 --- a/tests/InstallerTest.php +++ b/tests/InstallerTest.php @@ -1,5 +1,7 @@ assertEquals( @@ -28,7 +29,7 @@ public function testFindInstallableVersionsChecksForSuffix() ['version' => '1.0.1'], ['version' => '1.0.2-beta'], ['version' => '1.0.3-dev'], - ], PHP_VERSION, ['beta']) + ], PHP_VERSION, ['beta']), ); $this->assertEquals( [ @@ -41,21 +42,21 @@ public function testFindInstallableVersionsChecksForSuffix() ['version' => '1.0.1'], ['version' => '1.0.2-beta'], ['version' => '1.0.3-dev'], - ], PHP_VERSION, ['stable', 'beta']) + ], PHP_VERSION, ['stable', 'beta']), ); } - public function testFindInstallableVersionsChecksFoMinPhp() + public function testFindInstallableVersionsChecksFoMinPhp(): void { $this->assertEmpty((new VersionResolver())->findInstallableVersions([ [ 'version' => '1.0.0', 'php' => ['min' => '5.5.9'], - ] + ], ], '5.5.0')); } - public function testFindLatestVersionWithMax() + public function testFindLatestVersionWithMax(): void { $this->assertEquals('3.0.0', (new VersionResolver())->findLatestVersion([ ['version' => '1.0.0'], @@ -65,7 +66,7 @@ public function testFindLatestVersionWithMax() ], '', '3.0.0')['version']); } - public function testFindLatestVersionWithMin() + public function testFindLatestVersionWithMin(): void { $this->assertEquals('3.0.1', (new VersionResolver())->findLatestVersion([ ['version' => '1.0.0'], @@ -83,7 +84,7 @@ public function testFindLatestVersionWithMin() ], 'v3.1'); } - public function testGetOption() + public function testGetOption(): void { $method = new \ReflectionMethod(Installer::class, 'getOption'); $method->setAccessible(true); diff --git a/tests/Local/ApplicationFinderTest.php b/tests/Local/ApplicationFinderTest.php index cc81a50b3e..676df3af6e 100644 --- a/tests/Local/ApplicationFinderTest.php +++ b/tests/Local/ApplicationFinderTest.php @@ -1,15 +1,16 @@ finder = new ApplicationFinder($config); } - public function testFindNestedApps() + public function testFindNestedApps(): void { $fakeAppRoot = 'tests/data/repositories/multiple/nest'; @@ -28,7 +29,7 @@ public function testFindNestedApps() $this->assertCount(3, $apps); } - public function testFindAppsUnderGroupedConfig() + public function testFindAppsUnderGroupedConfig(): void { $fakeAppRoot = 'tests/data/repositories/multi-grouped-config'; @@ -36,7 +37,7 @@ public function testFindAppsUnderGroupedConfig() $this->assertCount(3, $apps); } - public function testDetectMultiple() + public function testDetectMultiple(): void { $fakeRepositoryRoot = 'tests/data/repositories/multiple'; diff --git a/tests/Local/BuildFlavor/BuildFlavorTestBase.php b/tests/Local/BuildFlavor/BuildFlavorTestBase.php index eb73e78f57..8400871747 100644 --- a/tests/Local/BuildFlavor/BuildFlavorTestBase.php +++ b/tests/Local/BuildFlavor/BuildFlavorTestBase.php @@ -1,8 +1,11 @@ true]; + /** @var array */ + protected array $buildSettings = ['no-clean' => true]; /** * {@inheritdoc} @@ -38,17 +39,17 @@ abstract class BuildFlavorTestBase extends TestCase public static function setUpBeforeClass(): void { $container = Container::instance(); - $container->set('input', new ArrayInput([])); + $container->set(InputInterface::class, new ArrayInput([])); - self::$output = new ConsoleOutput(ConsoleOutput::VERBOSITY_NORMAL, false); - $container->set('output', self::$output); + self::$output = new ConsoleOutput(OutputInterface::VERBOSITY_NORMAL, false); + $container->set(OutputInterface::class, self::$output); self::$config = (new CliConfig())->withOverrides([ // We rename the app config file to avoid confusion when building the // CLI itself on platform.sh 'service.app_config_file' => '_platform.app.yaml', ]); - $container->set('config', self::$config); + $container->set(Config::class, self::$config); self::$container = $container; } @@ -58,7 +59,7 @@ public static function setUpBeforeClass(): void */ public function setUp(): void { - $this->builder = self::$container->get('local.build'); + $this->builder = self::$container->get(LocalBuild::class); $this->tempDirSetUp(); } @@ -68,15 +69,15 @@ public function setUp(): void * @param string $sourceDir * A directory containing source code for the project or app. Files will * be copied into a dummy project. - * @param array $buildSettings + * @param array $buildSettings * An array of custom build settings. - * @param bool $expectedResult + * @param bool $expectedResult * The expected build result. * * @return string * The project root for the dummy project. */ - protected function assertBuildSucceeds($sourceDir, array $buildSettings = [], $expectedResult = true) + protected function assertBuildSucceeds(string $sourceDir, array $buildSettings = [], bool $expectedResult = true): string { $projectRoot = $this->createDummyProject($sourceDir); self::$output->writeln("\nTesting build for directory: " . $sourceDir); @@ -91,7 +92,7 @@ protected function assertBuildSucceeds($sourceDir, array $buildSettings = [], $e * * @return string */ - protected function createDummyProject($sourceDir) + protected function createDummyProject(string $sourceDir): string { if (!is_dir($sourceDir)) { throw new \InvalidArgumentException("Not a directory: $sourceDir"); @@ -108,7 +109,9 @@ protected function createDummyProject($sourceDir) $cwd = getcwd(); chdir($projectRoot); exec('git init'); - chdir($cwd); + if ($cwd) { + chdir($cwd); + } $local->ensureGitRemote($projectRoot, 'testProjectId'); $local->writeCurrentProjectConfig(['id' => 'testProjectId'], $projectRoot); diff --git a/tests/Local/BuildFlavor/ComposerTest.php b/tests/Local/BuildFlavor/ComposerTest.php index 4b45bed18d..08a7813ec9 100644 --- a/tests/Local/BuildFlavor/ComposerTest.php +++ b/tests/Local/BuildFlavor/ComposerTest.php @@ -1,39 +1,40 @@ assertBuildSucceeds('tests/data/apps/composer'); - $webRoot = $projectRoot . '/' . self::$config->get('local.web_root'); + $webRoot = $projectRoot . '/' . self::$config->getStr('local.web_root'); $this->assertFileExists($webRoot . '/vendor/psr/log/README.md'); } - public function testBuildComposerCustomPhp() + public function testBuildComposerCustomPhp(): void { $this->assertBuildSucceeds('tests/data/apps/composer-php56'); } - public function testBuildComposerHhvm() + public function testBuildComposerHhvm(): void { $this->assertBuildSucceeds('tests/data/apps/hhvm37'); } - public function testBuildComposerMounts() + public function testBuildComposerMounts(): void { $projectRoot = $this->assertBuildSucceeds('tests/data/apps/composer-mounts', [ 'copy' => true, 'abslinks' => true, ]); - $webRoot = $projectRoot . '/' . self::$config->get('local.web_root'); - $shared = $projectRoot . '/' . self::$config->get('local.shared_dir'); - $buildDir = $projectRoot . '/' . self::$config->get('local.build_dir') . '/default'; + $webRoot = $projectRoot . '/' . self::$config->getStr('local.web_root'); + $shared = $projectRoot . '/' . self::$config->getStr('local.shared_dir'); + $buildDir = $projectRoot . '/' . self::$config->getStr('local.build_dir') . '/default'; $this->assertFileExists($webRoot . '/js'); $this->assertFileExists($webRoot . '/css'); @@ -48,7 +49,7 @@ public function testBuildComposerMounts() * for an application which does not contain a composer.json file. The build * may not do much, but at least it should not throw an exception. */ - public function testBuildFakeSymfony() + public function testBuildFakeSymfony(): void { $this->assertBuildSucceeds('tests/data/apps/fake-symfony'); } @@ -56,7 +57,7 @@ public function testBuildFakeSymfony() /** * Test the deprecated config file format still works. */ - public function testBuildDeprecatedConfig() + public function testBuildDeprecatedConfig(): void { $this->assertBuildSucceeds('tests/data/apps/deprecated-config'); } diff --git a/tests/Local/BuildFlavor/DependenciesTest.php b/tests/Local/BuildFlavor/DependenciesTest.php index 3e5324c1d1..3d6bbdd510 100644 --- a/tests/Local/BuildFlavor/DependenciesTest.php +++ b/tests/Local/BuildFlavor/DependenciesTest.php @@ -1,27 +1,28 @@ assertBuildSucceeds($this->sourceDir, ['no-deps' => true, 'no-build-hooks' => true]); } - public function testBuildFailsIfDepsNotInstalled() + public function testBuildFailsIfDepsNotInstalled(): void { $this->assertBuildSucceeds($this->sourceDir, ['no-deps' => true], false); } - public function testBuildSucceedsIfNodejsDepsInstalled() + public function testBuildSucceedsIfNodejsDepsInstalled(): void { $shell = new Shell(); if ($shell->commandExists('npm')) { @@ -31,7 +32,7 @@ public function testBuildSucceedsIfNodejsDepsInstalled() } } - public function testBuildSucceedsIfPhpDepsInstalled() + public function testBuildSucceedsIfPhpDepsInstalled(): void { $shell = new Shell(); if ($shell->commandExists('composer')) { @@ -41,7 +42,7 @@ public function testBuildSucceedsIfPhpDepsInstalled() } } - public function testBuildSucceedsIfPythonDepsInstalled() + public function testBuildSucceedsIfPythonDepsInstalled(): void { $shell = new Shell(); if ($shell->commandExists('pip') || $shell->commandExists('pip3')) { @@ -51,9 +52,8 @@ public function testBuildSucceedsIfPythonDepsInstalled() try { $this->assertBuildSucceeds($this->sourceDir . '/python'); } catch (\RuntimeException $e) { - if (\getenv('TRAVIS') && strpos($e->getMessage(), 'The command failed') !== false && strpos($e->getMessage(), 'pip install') !== false) { + if (\getenv('TRAVIS') && str_contains($e->getMessage(), 'The command failed') && str_contains($e->getMessage(), 'pip install')) { $this->markTestSkipped('Installing python dependencies is known to fail on Travis'); - return; } throw $e; } @@ -62,7 +62,7 @@ public function testBuildSucceedsIfPythonDepsInstalled() } } - public function testBuildSucceedsIfRubyDepsInstalled() + public function testBuildSucceedsIfRubyDepsInstalled(): void { $shell = new Shell(); if ($shell->commandExists('bundle')) { diff --git a/tests/Local/BuildFlavor/DrupalTest.php b/tests/Local/BuildFlavor/DrupalTest.php index cdd139d858..420312b734 100644 --- a/tests/Local/BuildFlavor/DrupalTest.php +++ b/tests/Local/BuildFlavor/DrupalTest.php @@ -1,13 +1,14 @@ createDummyProject($sourceDir); - $webRoot = $projectRoot . '/' . self::$config->get('local.web_root'); - $shared = $projectRoot . '/' . self::$config->get('local.shared_dir'); - $buildDir = $projectRoot . '/' . self::$config->get('local.build_dir') . '/default'; + $webRoot = $projectRoot . '/' . self::$config->getStr('local.web_root'); + $shared = $projectRoot . '/' . self::$config->getStr('local.shared_dir'); + $buildDir = $projectRoot . '/' . self::$config->getStr('local.build_dir') . '/default'; // Insert a dummy file into 'shared'. if (!file_exists($shared)) { - mkdir($shared, 0755, true); + mkdir($shared, 0o755, true); } touch($shared . '/symlink_me'); @@ -71,10 +72,10 @@ public function testBuildDrupalInProjectMode() $this->assertTrue($success2, 'Second build success for dir: ' . $sourceDir); } - public function testBuildDrupalInProfileMode() + public function testBuildDrupalInProfileMode(): void { $projectRoot = $this->assertBuildSucceeds('tests/data/apps/drupal/profile'); - $webRoot = $projectRoot . '/' . self::$config->get('local.web_root'); + $webRoot = $projectRoot . '/' . self::$config->getStr('local.web_root'); $this->assertFileExists($webRoot . '/index.php'); $this->assertFileExists($webRoot . '/sites/default/settings.php'); $this->assertFileExists($webRoot . '/profiles/test/test.profile'); @@ -82,7 +83,7 @@ public function testBuildDrupalInProfileMode() $this->assertFileExists($webRoot . '/profiles/test/modules/test_module/test_module_file.php'); } - public function testBuildUpdateLock() + public function testBuildUpdateLock(): void { $sourceDir = 'tests/data/apps/drupal/yaml'; self::$output->writeln("\nTesting build (with --lock) for directory: " . $sourceDir); @@ -95,7 +96,7 @@ public function testBuildUpdateLock() * * This is not Drupal-specific, but this is the simplest example. */ - public function testArchiveAndExtract() + public function testArchiveAndExtract(): void { $projectRoot = $this->createDummyProject('tests/data/apps/drupal/project'); @@ -108,7 +109,7 @@ public function testArchiveAndExtract() // Build. This should create an archive. $this->builder->build($this->buildSettings, $projectRoot); - $archive = $projectRoot . '/' . self::$config->get('local.archive_dir') .'/' . $treeId . '.tar.gz'; + $archive = $projectRoot . '/' . self::$config->getStr('local.archive_dir') . '/' . $treeId . '.tar.gz'; $this->assertFileExists($archive); // Build again. This will extract the archive. @@ -116,7 +117,7 @@ public function testArchiveAndExtract() $this->assertTrue($success); } - public function testDoNotSymlinkBuildsIntoSitesDefault() + public function testDoNotSymlinkBuildsIntoSitesDefault(): void { $repository = $this->createTempSubDir('repo'); $fsHelper = new Filesystem(); diff --git a/tests/Local/BuildFlavor/InvalidAppTest.php b/tests/Local/BuildFlavor/InvalidAppTest.php index eb01196b90..6e43de06a5 100644 --- a/tests/Local/BuildFlavor/InvalidAppTest.php +++ b/tests/Local/BuildFlavor/InvalidAppTest.php @@ -1,12 +1,16 @@ expectException(InvalidConfigException::class); $this->expectExceptionMessage('Configuration file not found'); diff --git a/tests/Local/BuildFlavor/NodeJsTest.php b/tests/Local/BuildFlavor/NodeJsTest.php index e4f859391d..d0503ab549 100644 --- a/tests/Local/BuildFlavor/NodeJsTest.php +++ b/tests/Local/BuildFlavor/NodeJsTest.php @@ -1,18 +1,20 @@ assertBuildSucceeds('tests/data/apps/nodejs'); } - public function testBuildNodeJsCopy() + public function testBuildNodeJsCopy(): void { $this->assertBuildSucceeds('tests/data/apps/nodejs', ['copy' => true]); } diff --git a/tests/Local/BuildFlavor/NoneTest.php b/tests/Local/BuildFlavor/NoneTest.php index 49e574d1ac..a46dace6db 100644 --- a/tests/Local/BuildFlavor/NoneTest.php +++ b/tests/Local/BuildFlavor/NoneTest.php @@ -1,16 +1,18 @@ assertBuildSucceeds('tests/data/apps/none'); - $webRoot = $projectRoot . '/' . self::$config->get('local.web_root'); + $webRoot = $projectRoot . '/' . self::$config->getStr('local.web_root'); $this->assertFileExists($webRoot . '/index.html'); } } diff --git a/tests/Local/BuildFlavor/VanillaTest.php b/tests/Local/BuildFlavor/VanillaTest.php index 2ee91d2a40..c60ae9ad6f 100644 --- a/tests/Local/BuildFlavor/VanillaTest.php +++ b/tests/Local/BuildFlavor/VanillaTest.php @@ -1,29 +1,30 @@ assertBuildSucceeds('tests/data/apps/vanilla'); - $webRoot = $projectRoot . '/' . self::$config->get('local.web_root'); + $webRoot = $projectRoot . '/' . self::$config->getStr('local.web_root'); $this->assertFileExists($webRoot . '/index.html'); } /** * Test building without symlinks. */ - public function testBuildNoSymlinks() + public function testBuildNoSymlinks(): void { $sourceDir = 'tests/data/apps/vanilla'; $projectRoot = $this->assertBuildSucceeds($sourceDir, ['copy' => true]); - $webRoot = $projectRoot . '/' . self::$config->get('local.web_root'); + $webRoot = $projectRoot . '/' . self::$config->getStr('local.web_root'); $this->assertFileExists($webRoot . '/index.html'); $this->assertTrue(is_dir($webRoot), 'Web root is an actual directory'); } @@ -31,20 +32,20 @@ public function testBuildNoSymlinks() /** * Test building with a custom web root. */ - public function testBuildCustomWebRoot() + public function testBuildCustomWebRoot(): void { $projectRoot = $this->assertBuildSucceeds('tests/data/apps/vanilla-webroot'); - $webRoot = $projectRoot . '/' . self::$config->get('local.web_root'); + $webRoot = $projectRoot . '/' . self::$config->getStr('local.web_root'); $this->assertFileExists($webRoot . '/index.html'); $projectRoot = $this->assertBuildSucceeds('tests/data/apps/vanilla-webroot', ['copy' => true]); - $webRoot = $projectRoot . '/' . self::$config->get('local.web_root'); + $webRoot = $projectRoot . '/' . self::$config->getStr('local.web_root'); $this->assertFileExists($webRoot . '/index.html'); } /** * Test with a custom source and destination. */ - public function testBuildCustomSourceDestination() + public function testBuildCustomSourceDestination(): void { // Copy the 'vanilla' app to a temporary directory. $sourceDir = $this->createTempSubDir(); @@ -69,7 +70,7 @@ public function testBuildCustomSourceDestination() /** * Test with a custom destination. */ - public function testBuildCustomDestination() + public function testBuildCustomDestination(): void { $projectRoot = $this->createDummyProject('tests/data/apps/vanilla'); diff --git a/tests/Local/LocalApplicationTest.php b/tests/Local/LocalApplicationTest.php index 16cbbfb533..510e2634e0 100644 --- a/tests/Local/LocalApplicationTest.php +++ b/tests/Local/LocalApplicationTest.php @@ -1,5 +1,7 @@ assertInstanceOf(Drupal::class, $app->getBuildFlavor()); } - public function testBuildFlavorDetectionSymfony() + public function testBuildFlavorDetectionSymfony(): void { $appRoot = 'tests/data/apps/symfony'; @@ -44,7 +45,7 @@ public function testBuildFlavorDetectionSymfony() /** * Test the special case of HHVM buildFlavor types being the same as PHP. */ - public function testBuildFlavorAliasHhvm() + public function testBuildFlavorAliasHhvm(): void { $appRoot = 'tests/data/apps/vanilla'; @@ -57,7 +58,7 @@ public function testBuildFlavorAliasHhvm() $this->assertInstanceOf(Symfony::class, $buildFlavor); } - public function testBuildFlavorDetectionNone() + public function testBuildFlavorDetectionNone(): void { $fakeAppRoot = 'tests/data/apps/none'; @@ -65,7 +66,7 @@ public function testBuildFlavorDetectionNone() $this->assertInstanceOf(NoBuildFlavor::class, $app->getBuildFlavor(), 'Config does not indicate a specific build flavor'); } - public function testGetAppConfig() + public function testGetAppConfig(): void { $fakeAppRoot = 'tests/data/repositories/multiple/simple'; @@ -75,7 +76,7 @@ public function testGetAppConfig() $this->assertEquals('simple', $app->getId()); } - public function testGetAppConfigNested() + public function testGetAppConfigNested(): void { $fakeAppRoot = 'tests/data/repositories/multiple/nest/nested'; @@ -86,7 +87,7 @@ public function testGetAppConfigNested() $this->assertEquals('nested1', $app->getId()); } - public function testGetSharedFileMounts() + public function testGetSharedFileMounts(): void { $appRoot = 'tests/data/apps/drupal/project'; $app = new LocalApplication($appRoot, $this->config); diff --git a/tests/Local/LocalBuildTest.php b/tests/Local/LocalBuildTest.php index 9197b8ec3a..f6d61b6154 100644 --- a/tests/Local/LocalBuildTest.php +++ b/tests/Local/LocalBuildTest.php @@ -1,26 +1,32 @@ set('input', new ArrayInput([])); - /** @var LocalBuild localBuild */ - $this->localBuild = $container->get('local.build'); + $container->set(Config::class, new Config([], __DIR__ . '/../data/mock-cli-config.yaml')); + $container->set(InputInterface::class, new ArrayInput([])); + $container->set(OutputInterface::class, new BufferedOutput()); + $this->localBuild = $container->get(LocalBuild::class); } - public function testGetTreeId() + public function testGetTreeId(): void { $treeId = $this->localBuild->getTreeId('tests/data/apps/composer', []); $this->assertEquals('0d9f5dd9a2907d905efc298686bb3c4e2f9a4811', $treeId); diff --git a/tests/Local/LocalProjectTest.php b/tests/Local/LocalProjectTest.php index 6f79be8a2f..83a85f6512 100644 --- a/tests/Local/LocalProjectTest.php +++ b/tests/Local/LocalProjectTest.php @@ -1,5 +1,7 @@ tempDirSetUp(); $testDir = $this->tempDir; - mkdir("$testDir/1/2/3/4/5", 0755, true); + mkdir("$testDir/1/2/3/4/5", 0o755, true); $expectedRoot = "$testDir/1"; $config = new Config(); $this->assertTrue($config->has('local.project_config_legacy')); - touch("$expectedRoot/" . $config->get('local.project_config_legacy')); + touch("$expectedRoot/" . $config->getStr('local.project_config_legacy')); chdir($testDir); $localProject = new LocalProject(); diff --git a/tests/MockApp.php b/tests/MockApp.php new file mode 100644 index 0000000000..cd6a96f7de --- /dev/null +++ b/tests/MockApp.php @@ -0,0 +1,48 @@ +setIO(new ArrayInput([]), new NullOutput()); + self::$application->setAutoExit(false); + } + + return self::$application; + } + + /** + * @param array $otherArgs + */ + public static function runAndReturnOutput(string $command, array $otherArgs = [], ?int $verbosity = null): string + { + $app = MockApp::instance(); + $input = new ArrayInput(array_merge([$command], $otherArgs)); + $input->setInteractive(false); + $output = new BufferedOutput($verbosity); + if (!chdir(sys_get_temp_dir())) { + throw new \Exception('Cannot change directory'); + } + $exitCode = $app->run($input, $output); + if ($exitCode !== 0) { + throw new \RuntimeException(sprintf("Running test command returned exit code %d and output:\n%s", $exitCode, $output->fetch())); + } + + return $output->fetch(); + } +} diff --git a/tests/Model/VariableTest.php b/tests/Model/VariableTest.php index 9eac706db0..78be275734 100644 --- a/tests/Model/VariableTest.php +++ b/tests/Model/VariableTest.php @@ -1,5 +1,7 @@ assertEquals( ['env', 'foo', 'bar'], - (new Variable())->parse('env:foo=bar') + (new Variable())->parse('env:foo=bar'), ); $this->assertEquals( ['env', 'foo:oof', 'bar'], - (new Variable())->parse('env:foo:oof=bar') + (new Variable())->parse('env:foo:oof=bar'), ); $this->assertEquals( ['env', 'foo.123', 'bar'], - (new Variable())->parse('env:foo.123=bar') + (new Variable())->parse('env:foo.123=bar'), ); $this->assertEquals( ['complex', 'json', '{"foo":"bar"}'], - (new Variable())->parse('complex:json={"foo":"bar"}') + (new Variable())->parse('complex:json={"foo":"bar"}'), ); $this->assertEquals( ['empty', 'value', ''], - (new Variable())->parse('empty:value=') + (new Variable())->parse('empty:value='), ); } - public function testParseInvalidVariableType() + public function testParseInvalidVariableType(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid variable type'); @@ -45,26 +47,29 @@ public function testParseInvalidVariableType() } - public function testParseInvalidVariableName() + public function testParseInvalidVariableName(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid variable name'); (new Variable())->parse('a:b(c)=d'); } - public function testParseVariableWithNoDelimiter() { + public function testParseVariableWithNoDelimiter(): void + { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage($this->invalidMessage); (new Variable())->parse('foo'); } - public function testParseVariableWithWrongDelimiterOrder() { + public function testParseVariableWithWrongDelimiterOrder(): void + { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage($this->invalidMessage); (new Variable())->parse('a=b:c'); } - public function testParseVariableWithEmptyType() { + public function testParseVariableWithEmptyType(): void + { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage($this->invalidMessage); (new Variable())->parse(':b=c'); diff --git a/tests/Service/DrushServiceTest.php b/tests/Service/DrushServiceTest.php index 6ccb0fe092..da50c8fc0f 100644 --- a/tests/Service/DrushServiceTest.php +++ b/tests/Service/DrushServiceTest.php @@ -1,7 +1,10 @@ tempDirSetUp(); } - public function testCreateAliases() + public function testCreateAliases(): void { // Set up file structure. $testDir = $this->createTempSubDir(); @@ -80,12 +77,12 @@ public function testCreateAliases() // Check that YAML aliases exist. $this->assertFileExists($homeDir . '/.drush/site-aliases/test.site.yml'); - $aliases = Yaml::parse(file_get_contents($homeDir . '/.drush/site-aliases/test.site.yml')); + $aliases = Yaml::parse((string) file_get_contents($homeDir . '/.drush/site-aliases/test.site.yml')); $this->assertArrayHasKey('main', $aliases); $this->assertArrayHasKey('_local', $aliases); } - public function testCreateAliasesMultiApp() + public function testCreateAliasesMultiApp(): void { // Set up file structure. $testDir = $this->createTempSubDir(); @@ -117,7 +114,7 @@ public function testCreateAliasesMultiApp() $this->assertCount(1, $apps); } - public function testCreateAliasesMultiDrupal() + public function testCreateAliasesMultiDrupal(): void { // Set up file structure. $testDir = $this->createTempSubDir(); @@ -150,14 +147,14 @@ public function testCreateAliasesMultiDrupal() // Check that YAML aliases exist. $this->assertFileExists($homeDir . '/.drush/site-aliases/test.site.yml'); - $aliases = Yaml::parse(file_get_contents($homeDir . '/.drush/site-aliases/test.site.yml')); + $aliases = Yaml::parse((string) file_get_contents($homeDir . '/.drush/site-aliases/test.site.yml')); $this->assertArrayHasKey('main--drupal1', $aliases); $this->assertArrayHasKey('_local--drupal1', $aliases); $this->assertArrayHasKey('main--drupal2', $aliases); $this->assertArrayHasKey('_local--drupal2', $aliases); } - public function testGetSiteAliasDir() + public function testGetSiteAliasDir(): void { // Set up file structure. $testDir = $this->createTempSubDir(); diff --git a/tests/Service/FilesystemServiceTest.php b/tests/Service/FilesystemServiceTest.php index 64955e4dd6..1a5406e03e 100644 --- a/tests/Service/FilesystemServiceTest.php +++ b/tests/Service/FilesystemServiceTest.php @@ -1,5 +1,7 @@ tempDir(); $this->assertTrue(is_dir($tempDir)); @@ -38,7 +39,7 @@ public function testTempDir() /** * Test FilesystemHelper::remove() on directories. */ - public function testRemoveDir() + public function testRemoveDir(): void { // Create a test directory containing some files in several levels. $testDir = $this->tempDir(true); @@ -51,7 +52,7 @@ public function testRemoveDir() /** * Test FilesystemHelper::copyAll(). */ - public function testCopyAll() + public function testCopyAll(): void { $source = $this->tempDir(true); $destination = $this->tempDir(); @@ -69,7 +70,7 @@ public function testCopyAll() /** * Test FilesystemHelper::symlinkDir(). */ - public function testSymlinkDir() + public function testSymlinkDir(): void { $testTarget = $this->tempDir(); $testLink = $this->tempDir() . '/link'; @@ -82,7 +83,7 @@ public function testSymlinkDir() /** * Test FilesystemHelper::makePathAbsolute(). */ - public function testMakePathAbsolute() + public function testMakePathAbsolute(): void { $testDir = $this->tempDir(); chdir($testDir); @@ -107,7 +108,7 @@ public function testMakePathAbsolute() /** * Test FilesystemHelper::symlinkAll(). */ - public function testSymlinkAll() + public function testSymlinkAll(): void { $testSource = $this->tempDir(true); $testDestination = $this->tempDir(); @@ -129,7 +130,7 @@ public function testSymlinkAll() // Test with relative links. This has no effect on Windows. $testDestination = $this->tempDir(); - $this->fs->setRelativeLinks(true); + $this->fs->setRelativeLinks(); $this->fs->symlinkAll($testSource, $testDestination); $this->fs->setRelativeLinks(false); $this->assertFileExists($testDestination . '/test-file'); @@ -145,9 +146,9 @@ public function testSymlinkAll() $this->assertFileExists($testDestination . '/test-nesting/1/2/3/test-file'); } - public function testCanWrite() + public function testCanWrite(): void { - \umask(0002); + \umask(0o002); $testDir = $this->createTempSubDir(); if (touch($testDir . '/test-file')) { @@ -156,10 +157,10 @@ public function testCanWrite() $this->markTestIncomplete('Failed to create file: ' . $testDir . '/test-file'); } - chmod($testDir . '/test-file', 0500); + chmod($testDir . '/test-file', 0o500); $this->assertEquals(\is_writable($testDir . '/test-file'), $this->fs->canWrite($testDir . '/test-file')); - if (mkdir($testDir . '/test-dir', 0700)) { + if (mkdir($testDir . '/test-dir', 0o700)) { $this->assertTrue($this->fs->canWrite($testDir . '/test-dir')); $this->assertTrue($this->fs->canWrite($testDir . '/test-dir/1')); $this->assertTrue($this->fs->canWrite($testDir . '/test-dir/1/2/3')); @@ -167,7 +168,7 @@ public function testCanWrite() $this->markTestIncomplete('Failed to create directory: ' . $testDir . '/test-dir'); } - if (mkdir($testDir . '/test-ro-dir', 0500)) { + if (mkdir($testDir . '/test-ro-dir', 0o500)) { $this->assertEquals(is_writable($testDir . '/test-ro-dir'), $this->fs->canWrite($testDir . '/test-ro-dir')); $this->assertEquals(is_writable($testDir . '/test-ro-dir'), $this->fs->canWrite($testDir . '/test-ro-dir/1')); } else { @@ -182,14 +183,14 @@ public function testCanWrite() * * @return string */ - protected function tempDir($fill = false) + protected function tempDir(?bool $fill = false): string { $testDir = $this->createTempSubDir(); if ($fill) { touch($testDir . '/test-file'); mkdir($testDir . '/test-dir'); touch($testDir . '/test-dir/test-file'); - mkdir($testDir . '/test-nesting/1/2/3', 0755, true); + mkdir($testDir . '/test-nesting/1/2/3', 0o755, true); touch($testDir . '/test-nesting/1/2/3/test-file'); } diff --git a/tests/Service/GitDataApiServiceTest.php b/tests/Service/GitDataApiServiceTest.php index 339bdcfb3b..19350564d6 100644 --- a/tests/Service/GitDataApiServiceTest.php +++ b/tests/Service/GitDataApiServiceTest.php @@ -1,5 +1,7 @@ tempDirSetUp(); $repository = $this->getRepositoryDir(); - if (!is_dir($repository) && !mkdir($repository, 0755, true)) { + if (!is_dir($repository) && !mkdir($repository, 0o755, true)) { throw new \Exception("Failed to create directories."); } - $container = Container::instance(); - $container->set('input', new ArrayInput([])); - - $this->git = $container->get('git'); - $this->git->init($repository, true); + $this->git = new Git(); + $this->git->init($repository, '', true); $this->git->setDefaultRepositoryDir($repository); chdir($repository); @@ -58,7 +52,7 @@ public function setUp(): void /** * Test GitHelper::ensureInstalled(). */ - public function testEnsureInstalled() + public function testEnsureInstalled(): void { $this->expectNotToPerformAssertions(); $this->git->ensureInstalled(); @@ -67,12 +61,12 @@ public function testEnsureInstalled() /** * Test GitHelper::isRepository(). */ - public function testGetRoot() + public function testGetRoot(): void { // Test a real repository. $repositoryDir = $this->getRepositoryDir(); $this->assertEquals($repositoryDir, $this->git->getRoot($repositoryDir)); - mkdir($repositoryDir . '/1/2/3/4/5', 0755, true); + mkdir($repositoryDir . '/1/2/3/4/5', 0o755, true); $this->assertEquals($repositoryDir, $this->git->getRoot($repositoryDir . '/1/2/3/4/5')); // Test a non-repository. @@ -87,7 +81,7 @@ public function testGetRoot() * * @return string */ - protected function getRepositoryDir() + protected function getRepositoryDir(): string { return $this->tempDir . '/repo'; } @@ -95,7 +89,7 @@ protected function getRepositoryDir() /** * Test GitHelper::checkOutNew(). */ - public function testCheckOutNew() + public function testCheckOutNew(): void { $this->assertTrue($this->git->checkOutNew('new')); $this->git->checkOut('main'); @@ -104,7 +98,7 @@ public function testCheckOutNew() /** * Test GitHelper::branchExists(). */ - public function testBranchExists() + public function testBranchExists(): void { $this->git->checkOutNew('existent'); $this->assertTrue($this->git->branchExists('existent')); @@ -114,7 +108,7 @@ public function testBranchExists() /** * Test GitHelper::branchExists() with unicode branch names. */ - public function testBranchExistsUnicode() + public function testBranchExistsUnicode(): void { $this->git->checkOutNew('b®åñçh-wî†h-üní¢ø∂é'); $this->assertTrue($this->git->branchExists('b®åñçh-wî†h-üní¢ø∂é')); @@ -123,7 +117,7 @@ public function testBranchExistsUnicode() /** * Test GitHelper::getCurrentBranch(). */ - public function testGetCurrentBranch() + public function testGetCurrentBranch(): void { $this->git->checkOutNew('test'); $this->assertEquals('test', $this->git->getCurrentBranch()); @@ -132,7 +126,7 @@ public function testGetCurrentBranch() /** * Test GitHelper::getConfig(). */ - public function testGetConfig() + public function testGetConfig(): void { $config = $this->git->getConfig('user.email'); $this->assertEquals('test@example.com', $config); diff --git a/tests/Service/IdentifierTest.php b/tests/Service/IdentifierTest.php index 6c2626e60b..8ce55f7695 100644 --- a/tests/Service/IdentifierTest.php +++ b/tests/Service/IdentifierTest.php @@ -1,5 +1,7 @@ withOverrides(['detection.cluster_header' => null, 'detection.site_domains' => ['example.site']]); } - public function testIdentify() + public function testIdentify(): void { $identifier = new Identifier($this->config()); @@ -83,7 +85,7 @@ public function testIdentify() $this->assertEquals($expected, $identifier->identify($url)); } - public function testIdentifyWithEnvironmentIdOf0() + public function testIdentifyWithEnvironmentIdOf0(): void { $identifier = new Identifier($this->config()); @@ -106,7 +108,7 @@ public function testIdentifyWithEnvironmentIdOf0() $this->assertEquals($expected, $identifier->identify($url)); } - public function testIdentifyWithHyphenPaths() + public function testIdentifyWithHyphenPaths(): void { $identifier = new Identifier($this->config()); diff --git a/tests/Service/ShellServiceTest.php b/tests/Service/ShellServiceTest.php index e740ba1fbc..5fe54cff51 100644 --- a/tests/Service/ShellServiceTest.php +++ b/tests/Service/ShellServiceTest.php @@ -1,22 +1,24 @@ assertTrue($shell->commandExists($workingCommand)); @@ -27,8 +29,22 @@ public function testExecute() $this->assertFalse($shell->execute(['which', 'nonexistent'])); // With $mustRun enabled. - $this->assertNotEmpty($shell->execute([$workingCommand], null, true)); + $this->assertNotEmpty($shell->execute([$workingCommand], mustRun: true)); $this->expectException(\Exception::class); - $shell->execute(['which', 'nonexistent'], null, true); + $shell->execute(['which', 'nonexistent'], mustRun: true); + } + + /** + * Test Shell::mustExecute(). + */ + public function testMustExecute(): void + { + $shell = new Shell(); + + $workingCommand = str_contains(PHP_OS, 'WIN') ? 'help' : 'pwd'; + + $this->assertNotEmpty($shell->mustExecute($workingCommand)); + $this->expectException(ProcessFailedException::class); + $shell->mustExecute(['which', 'nonexistent']); } } diff --git a/tests/Service/SshTest.php b/tests/Service/SshTest.php index 7ded6d23cb..a0cfa97d2d 100644 --- a/tests/Service/SshTest.php +++ b/tests/Service/SshTest.php @@ -1,5 +1,7 @@ set('input', new ArrayInput([])); - $container->set('config', (new Config())->withOverrides([ + $container->set(InputInterface::class, new ArrayInput([])); + $container->set(OutputInterface::class, new BufferedOutput()); + $container->set(Config::class, (new Config())->withOverrides([ 'ssh.domain_wildcards' => ['*.ssh.example.com'], ])); - $this->ssh = $container->get('ssh'); + $this->ssh = $container->get(Ssh::class); } - public function testGetHost() + public function testGetHost(): void { $method = new \ReflectionMethod($this->ssh, 'getHost'); $method->setAccessible(true); @@ -36,7 +42,7 @@ public function testGetHost() $this->assertFalse($method->invoke($this->ssh, '###')); } - public function testHostIsInternal() + public function testHostIsInternal(): void { $method = new \ReflectionMethod($this->ssh, 'hostIsInternal'); $method->setAccessible(true); diff --git a/tests/Service/TableServiceTest.php b/tests/Service/TableServiceTest.php index 2fe6e993e8..394f4bb9f0 100644 --- a/tests/Service/TableServiceTest.php +++ b/tests/Service/TableServiceTest.php @@ -1,5 +1,7 @@ assertEquals($expected, $tableService->columnsToDisplay($header)); $rows = [ - ['foo', 1, 2, 3], + ['foo', '1', '2', '3'], new TableSeparator(), - ['bar', 4, 5, 6], + ['bar', '4', '5', '6'], ]; $expected = (new Csv(',', "\n"))->format([ ['Value 2', 'Name'], @@ -57,7 +59,7 @@ public function testColumns() /** * Test that columns are validated. */ - public function testInvalidColumn() + public function testInvalidColumn(): void { $definition = new InputDefinition(); Table::configureInput($definition); diff --git a/tests/Util/CsvTest.php b/tests/Util/CsvTest.php index f9b845b7a7..9f8511dfcd 100644 --- a/tests/Util/CsvTest.php +++ b/tests/Util/CsvTest.php @@ -1,5 +1,7 @@ data = [ - ['Year', 'Make', 'Model', 'Description', 'Price'], - ['1997', 'Ford', 'E350', 'ac, abs, moon', '3000.00'], - ['1999', 'Chevy', 'Venture "Extended Edition"', '', '4900.00'], - ['1999', 'Chevy', 'Venture "Extended Edition, Very Large"', '', '5000.00'], - ['1996', 'Jeep', 'Grand Cherokee', "MUST SELL!\nair, moon roof, loaded", '4799.00'], - ]; - } + // Data from a Wikipedia example. + // https://en.wikipedia.org/wiki/Comma-separated_values + private const DATA = [ + ['Year', 'Make', 'Model', 'Description', 'Price'], + ['1997', 'Ford', 'E350', 'ac, abs, moon', '3000.00'], + ['1999', 'Chevy', 'Venture "Extended Edition"', '', '4900.00'], + ['1999', 'Chevy', 'Venture "Extended Edition, Very Large"', '', '5000.00'], + ['1996', 'Jeep', 'Grand Cherokee', "MUST SELL!\nair, moon roof, loaded", '4799.00'], + ]; - public function testRfc4180() + public function testRfc4180(): void { $expected = "Year,Make,Model,Description,Price\r\n" . "1997,Ford,E350,\"ac, abs, moon\",3000.00\r\n" . "1999,Chevy,\"Venture \"\"Extended Edition\"\"\",,4900.00\r\n" . "1999,Chevy,\"Venture \"\"Extended Edition, Very Large\"\"\",,5000.00\r\n" . "1996,Jeep,Grand Cherokee,\"MUST SELL!\r\nair, moon roof, loaded\",4799.00\r\n"; - $actual = (new Csv())->format($this->data); + $actual = (new Csv())->format(self::DATA); $this->assertEquals($expected, $actual); } - public function testTsv() + public function testTsv(): void { $expected = "Year\tMake\tModel\tDescription\tPrice\n" . "1997\tFord\tE350\tac, abs, moon\t3000.00\n" . "1999\tChevy\t\"Venture \"\"Extended Edition\"\"\"\t\t4900.00\n" . "1999\tChevy\t\"Venture \"\"Extended Edition, Very Large\"\"\"\t\t5000.00\n" . "1996\tJeep\tGrand Cherokee\t\"MUST SELL!\nair, moon roof, loaded\"\t4799.00"; - $actual = (new Csv("\t", "\n"))->format($this->data, false); + $actual = (new Csv("\t", "\n"))->format(self::DATA, false); $this->assertEquals($expected, $actual); } - public function testPlain() + public function testPlain(): void { $expected = "Year\tMake\tModel\tDescription\tPrice\n" . "1997\tFord\tE350\tac, abs, moon\t3000.00\n" . "1999\tChevy\tVenture \"Extended Edition\"\t\t4900.00\n" . "1999\tChevy\tVenture \"Extended Edition, Very Large\"\t\t5000.00\n" . "1996\tJeep\tGrand Cherokee\tMUST SELL! air, moon roof, loaded\t4799.00"; - $actual = (new PlainFormat())->format($this->data, false); + $actual = (new PlainFormat())->format(self::DATA, false); $this->assertEquals($expected, $actual); } } diff --git a/tests/Util/NestedArrayUtilTest.php b/tests/Util/NestedArrayUtilTest.php index 538d9928da..182f94c895 100644 --- a/tests/Util/NestedArrayUtilTest.php +++ b/tests/Util/NestedArrayUtilTest.php @@ -1,5 +1,7 @@ */ + private array $testArray = []; public function setUp(): void { @@ -22,7 +25,7 @@ public function setUp(): void ]; } - public function testGetValue() + public function testGetValue(): void { $this->assertEquals($this->testArray['a']['0'], $this->getValue('a.0')); $this->assertEquals($this->testArray['a']['0']['x'], $this->getValue('a.0.x')); @@ -33,15 +36,15 @@ public function testGetValue() $this->assertEquals(null, $this->getValue('d.foo')); } - public function testSetValue() + public function testSetValue(): void { NestedArrayUtil::setNestedArrayValue($this->testArray, ['a', 'foo'], 'bar'); $this->assertEquals('bar', $this->testArray['a']['foo']); - NestedArrayUtil::setNestedArrayValue($this->testArray, ['c', 2, 3], 'test'); + NestedArrayUtil::setNestedArrayValue($this->testArray, ['c', '2', '3'], 'test'); $this->assertEquals('test', $this->testArray['c'][2][3]); } - public function testKeyExists() + public function testKeyExists(): void { NestedArrayUtil::getNestedArrayValue($this->testArray, ['a', '0'], $keyExists); $this->assertTrue($keyExists); @@ -54,7 +57,7 @@ public function testKeyExists() * * @return mixed */ - private function getValue($property) + private function getValue(string $property): mixed { return NestedArrayUtil::getNestedArrayValue($this->testArray, explode('.', $property)); } diff --git a/tests/Util/OsUtilTest.php b/tests/Util/OsUtilTest.php index 2708e9ae59..39bf511e20 100644 --- a/tests/Util/OsUtilTest.php +++ b/tests/Util/OsUtilTest.php @@ -1,5 +1,7 @@ assertEquals( "'This isn'\\''t an argument!'", - OsUtil::escapePosixShellArg("This isn't an argument!") + OsUtil::escapePosixShellArg("This isn't an argument!"), ); $this->assertEquals( "'Yes it is'", - OsUtil::escapePosixShellArg("Yes it is") + OsUtil::escapePosixShellArg("Yes it is"), ); $this->assertEquals( "'No it isn'\\''t'", - OsUtil::escapePosixShellArg("No it isn't") + OsUtil::escapePosixShellArg("No it isn't"), ); } } diff --git a/tests/Util/PortUtilTest.php b/tests/Util/PortUtilTest.php index d75ee15aad..ccc67957f4 100644 --- a/tests/Util/PortUtilTest.php +++ b/tests/Util/PortUtilTest.php @@ -1,5 +1,7 @@ getPort(); @@ -16,22 +18,21 @@ public function testGetPortDoesNotReturnPortInUse() // Find a listening local port, try getPort() on the port number and // test that a new number is returned. exec('lsof -sTCP:LISTEN -i@127.0.0.1 -P -n', $output, $returnVar); - if ($returnVar === 0 && preg_match('/127\.0\.0\.1:([0-9]+)/', end($output), $matches)) { - $openPort = $matches[1]; + if ($returnVar === 0 && preg_match('/127\.0\.0\.1:([0-9]+)/', (string) end($output), $matches)) { + $openPort = (int) $matches[1]; $this->assertNotEquals($util->getPort($openPort), $openPort); - } - else { + } else { $this->markTestIncomplete('Failed to find open port'); } } - public function testGetPortDoesNotReturnUnsafePort() + public function testGetPortDoesNotReturnUnsafePort(): void { $util = new PortUtil(); $this->assertNotEquals(2049, $util->getPort(2049)); } - public function testGetPortReturnsValidPort() + public function testGetPortReturnsValidPort(): void { $util = new PortUtil(); $port = $util->getPort(rand(10000, 50000)); @@ -43,7 +44,7 @@ public function testGetPortReturnsValidPort() $util->getPort(70000); } - public function testValidatePort() + public function testValidatePort(): void { $util = new PortUtil(); $this->assertFalse($util->validatePort(22)); diff --git a/tests/Util/SnippeterTest.php b/tests/Util/SnippeterTest.php index 83896ab32c..9fffa60faa 100644 --- a/tests/Util/SnippeterTest.php +++ b/tests/Util/SnippeterTest.php @@ -1,5 +1,7 @@ dataDir = dirname(__DIR__) . '/data/snippeter'; } - public function testUpdate() + public function testUpdate(): void { - $contents = file_get_contents($this->dataDir . '/with-existing'); + $contents = (string) file_get_contents($this->dataDir . '/with-existing'); $result = (new Snippeter())->updateSnippet($contents, $this->snippet, $this->begin, $this->end); $expected = file_get_contents($this->dataDir . '/after-update-existing'); $this->assertEquals($expected, $result); } - public function testInsert() + public function testInsert(): void { - $contents = file_get_contents($this->dataDir . '/without'); + $contents = (string) file_get_contents($this->dataDir . '/without'); $result = (new Snippeter())->updateSnippet($contents, $this->snippet, $this->begin, $this->end); $expected = file_get_contents($this->dataDir . '/after-insert'); $this->assertEquals($expected, $result); diff --git a/tests/Util/SortTest.php b/tests/Util/SortTest.php index 1e4c22b982..76fda1e729 100644 --- a/tests/Util/SortTest.php +++ b/tests/Util/SortTest.php @@ -1,5 +1,7 @@ (object) ['foo' => 'a', 'bar' => '1', 'num' => 5], 2 => (object) ['foo' => 'd', 'bar' => '10', 'num' => 10], @@ -41,14 +44,15 @@ public function testSortObjects() { ]], ]; foreach ($cases as $i => $case) { - list($property, $reverse, $expected) = $case; + [$property, $reverse, $expected] = $case; $o = $objects; Sort::sortObjects($o, $property, $reverse); - $this->assertEquals($expected, $o, $i); + $this->assertEquals($expected, $o, (string) $i); } } - public function testCompareDomains() { + public function testCompareDomains(): void + { $arr = [ 'region-1.fxample.com', 'region-4.example.com', @@ -61,7 +65,7 @@ public function testCompareDomains() { 'region-2.fxample.com', 'region.example.com', ]; - \usort($arr, [Sort::class, 'compareDomains']); + \usort($arr, Sort::compareDomains(...)); $this->assertEquals([ 'a', 'example.com', diff --git a/tests/Util/SslUtilTest.php b/tests/Util/SslUtilTest.php index b7713b3635..97e238d9f1 100644 --- a/tests/Util/SslUtilTest.php +++ b/tests/Util/SslUtilTest.php @@ -1,5 +1,7 @@ dir = dirname(__DIR__) . '/data/ssl'; } - public function testValidate() + public function testValidate(): void { $result = (new SslUtil())->validate($this->dir . '/cert.pem', $this->dir . '/key.pem', [$this->dir . '/chain.crt']); $this->assertArrayHasKey('certificate', $result); @@ -22,14 +24,14 @@ public function testValidate() $this->assertCount(3, $result['chain']); } - public function testValidateWrongFilename() + public function testValidateWrongFilename(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('The private key file could not be read'); (new SslUtil())->validate($this->dir . '/cert.pem', $this->dir . '/nonexistent-key.pem', []); } - public function testValidateWrongKey() + public function testValidateWrongKey(): void { if (!\extension_loaded('openssl')) { $this->markTestIncomplete('openssl extension not loaded'); @@ -40,7 +42,7 @@ public function testValidateWrongKey() } } - public function testValidateInvalidKey() + public function testValidateInvalidKey(): void { if (!\extension_loaded('openssl')) { $this->markTestIncomplete('openssl extension not loaded'); @@ -51,7 +53,7 @@ public function testValidateInvalidKey() } } - public function testValidateInvalidCert() + public function testValidateInvalidCert(): void { if (!\extension_loaded('openssl')) { $this->markTestIncomplete('openssl extension not loaded'); diff --git a/tests/Util/StringUtilTest.php b/tests/Util/StringUtilTest.php index 738ef3773b..94049aed0e 100644 --- a/tests/Util/StringUtilTest.php +++ b/tests/Util/StringUtilTest.php @@ -1,5 +1,7 @@ $case) { - list($str, $begin, $end, $result) = $case; + [$str, $begin, $end, $result] = $case; $this->assertEquals($result, StringUtil::between($str, $begin, $end), "case $key"); } } diff --git a/tests/Util/TimezoneUtilTest.php b/tests/Util/TimezoneUtilTest.php index cb670fd1a4..89508d7c4e 100644 --- a/tests/Util/TimezoneUtilTest.php +++ b/tests/Util/TimezoneUtilTest.php @@ -1,5 +1,7 @@ assertEquals('Pacific/Galapagos', TimezoneUtil::getTimezone()); } - public function testGetTimezoneReturnsCurrent() + public function testGetTimezoneReturnsCurrent(): void { ini_set('date.timezone', 'Antarctica/McMurdo'); date_default_timezone_set('Antarctica/Troll'); $this->assertEquals('Antarctica/Troll', TimezoneUtil::getTimezone()); } - public function testGetTimezoneReturnsSomething() + public function testGetTimezoneReturnsSomething(): void { $this->assertNotEmpty(TimezoneUtil::getTimezone()); } - public function testConvertTz() + public function testConvertTz(): void { $util = new TimezoneUtil(); - $method = new \ReflectionMethod($util,'convertTz'); + $method = new \ReflectionMethod($util, 'convertTz'); $method->setAccessible(true); $dataDir = dirname(__DIR__) . DIRECTORY_SEPARATOR . 'data'; $cases = [ diff --git a/tests/Util/VersionUtilTest.php b/tests/Util/VersionUtilTest.php index 0d12bf0f59..8e00904e44 100644 --- a/tests/Util/VersionUtilTest.php +++ b/tests/Util/VersionUtilTest.php @@ -1,5 +1,7 @@ assertEquals(['1.0.1', '1.1.0', '2.0.0'], $util->nextVersions('1.0.0')); @@ -15,7 +17,7 @@ public function testNextVersions() $this->assertEquals(['2.0.3-beta', '2.1.0-beta', '3.0.0-beta'], $util->nextVersions('2.0.2-beta')); } - public function testMajorVersion() + public function testMajorVersion(): void { $util = new VersionUtil(); $this->assertEquals(1, $util->majorVersion('1.0.0')); @@ -26,7 +28,7 @@ public function testMajorVersion() $this->assertEquals(3, $util->majorVersion('3.1.0-beta')); } - public function testIsPreRelease() + public function testIsPreRelease(): void { $util = new VersionUtil(); $this->assertEquals(1, $util->majorVersion('1.0.0')); diff --git a/tests/Util/WildcardTest.php b/tests/Util/WildcardTest.php index ed51ee8415..fae536d76c 100644 --- a/tests/Util/WildcardTest.php +++ b/tests/Util/WildcardTest.php @@ -1,5 +1,7 @@ $case) { - list($subjects, $wildcards, $result) = $case; + [$subjects, $wildcards, $result] = $case; $this->assertEquals($result, Wildcard::select($subjects, $wildcards), "Case $i"); } } diff --git a/tests/Util/YamlParserTest.php b/tests/Util/YamlParserTest.php index be1df8f5e3..896454a4d3 100644 --- a/tests/Util/YamlParserTest.php +++ b/tests/Util/YamlParserTest.php @@ -1,5 +1,7 @@ parseFile($file); $expected = [ 'name' => 'complex-yaml', @@ -31,9 +33,9 @@ public function testParseValidYaml() $this->assertEquals($expected, $parsed); } - public function testParseInvalidYaml() + public function testParseInvalidYaml(): void { - $file = 'tests/data/apps/complex-yaml/_platform.app.yaml'; + $file = 'tests/data/apps/complex-yaml/complex-app.yaml'; $content = file_get_contents($file); $content .= "\ntest: !include nonexistent.yml"; $this->expectException(InvalidConfigException::class); @@ -41,17 +43,17 @@ public function testParseInvalidYaml() (new YamlParser())->parseContent($content, $file); } - public function testParseIndentedYaml() + public function testParseIndentedYaml(): void { $file = 'example.yaml'; $content = <<parseContent($content, $file); $this->assertEquals([ 'name' => 'example-indented-yaml', diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 09895d8473..6f43ba4faf 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -1,4 +1,7 @@ =7.1" + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" }, "require-dev": { - "amphp/php-cs-fixer-config": "dev-master", - "amphp/phpunit-util": "^1", - "ext-json": "*", - "jetbrains/phpstorm-stubs": "^2019.3", - "phpunit/phpunit": "^7 | ^8 | ^9", - "react/promise": "^2", - "vimeo/psalm": "^3.12" + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23.1" }, "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.x-dev" - } - }, "autoload": { "files": [ - "lib/functions.php", - "lib/Internal/functions.php" + "src/functions.php", + "src/Future/functions.php", + "src/Internal/functions.php" ], "psr-4": { - "Amp\\": "lib" + "Amp\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -53,10 +46,6 @@ "MIT" ], "authors": [ - { - "name": "Daniel Lowrey", - "email": "rdlowrey@php.net" - }, { "name": "Aaron Piotrowski", "email": "aaron@trowski.com" @@ -68,6 +57,10 @@ { "name": "Niklas Keller", "email": "me@kelunik.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" } ], "description": "A non-blocking concurrency framework for PHP applications.", @@ -84,9 +77,8 @@ "promise" ], "support": { - "irc": "irc://irc.freenode.org/amphp", "issues": "https://github.com/amphp/amp/issues", - "source": "https://github.com/amphp/amp/tree/v2.6.4" + "source": "https://github.com/amphp/amp/tree/v3.0.2" }, "funding": [ { @@ -94,41 +86,45 @@ "type": "github" } ], - "time": "2024-03-21T18:52:26+00:00" + "time": "2024-05-10T21:37:46+00:00" }, { "name": "amphp/byte-stream", - "version": "v1.8.2", + "version": "v2.1.1", "source": { "type": "git", "url": "https://github.com/amphp/byte-stream.git", - "reference": "4f0e968ba3798a423730f567b1b50d3441c16ddc" + "reference": "daa00f2efdbd71565bf64ffefa89e37542addf93" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/byte-stream/zipball/4f0e968ba3798a423730f567b1b50d3441c16ddc", - "reference": "4f0e968ba3798a423730f567b1b50d3441c16ddc", + "url": "https://api.github.com/repos/amphp/byte-stream/zipball/daa00f2efdbd71565bf64ffefa89e37542addf93", + "reference": "daa00f2efdbd71565bf64ffefa89e37542addf93", "shasum": "" }, "require": { - "amphp/amp": "^2", - "php": ">=7.1" + "amphp/amp": "^3", + "amphp/parser": "^1.1", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2.3" }, "require-dev": { - "amphp/php-cs-fixer-config": "dev-master", - "amphp/phpunit-util": "^1.4", - "friendsofphp/php-cs-fixer": "^2.3", - "jetbrains/phpstorm-stubs": "^2019.3", - "phpunit/phpunit": "^6 || ^7 || ^8", - "psalm/phar": "^3.11.4" + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.22.1" }, "type": "library", "autoload": { "files": [ - "lib/functions.php" + "src/functions.php", + "src/Internal/functions.php" ], "psr-4": { - "Amp\\ByteStream\\": "lib" + "Amp\\ByteStream\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -157,7 +153,7 @@ ], "support": { "issues": "https://github.com/amphp/byte-stream/issues", - "source": "https://github.com/amphp/byte-stream/tree/v1.8.2" + "source": "https://github.com/amphp/byte-stream/tree/v2.1.1" }, "funding": [ { @@ -165,45 +161,39 @@ "type": "github" } ], - "time": "2024-04-13T18:00:56+00:00" + "time": "2024-02-17T04:49:38+00:00" }, { - "name": "amphp/parallel", - "version": "v1.4.3", + "name": "amphp/cache", + "version": "v2.0.1", "source": { "type": "git", - "url": "https://github.com/amphp/parallel.git", - "reference": "3aac213ba7858566fd83d38ccb85b91b2d652cb0" + "url": "https://github.com/amphp/cache.git", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/parallel/zipball/3aac213ba7858566fd83d38ccb85b91b2d652cb0", - "reference": "3aac213ba7858566fd83d38ccb85b91b2d652cb0", + "url": "https://api.github.com/repos/amphp/cache/zipball/46912e387e6aa94933b61ea1ead9cf7540b7797c", + "reference": "46912e387e6aa94933b61ea1ead9cf7540b7797c", "shasum": "" }, "require": { - "amphp/amp": "^2", - "amphp/byte-stream": "^1.6.1", - "amphp/parser": "^1", - "amphp/process": "^1", + "amphp/amp": "^3", "amphp/serialization": "^1", - "amphp/sync": "^1.0.1", - "php": ">=7.1" + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" }, "require-dev": { - "amphp/php-cs-fixer-config": "dev-master", - "amphp/phpunit-util": "^1.1", - "phpunit/phpunit": "^8 || ^7" + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" }, "type": "library", "autoload": { - "files": [ - "lib/Context/functions.php", - "lib/Sync/functions.php", - "lib/Worker/functions.php" - ], "psr-4": { - "Amp\\Parallel\\": "lib" + "Amp\\Cache\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -211,27 +201,112 @@ "MIT" ], "authors": [ + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, { "name": "Aaron Piotrowski", "email": "aaron@trowski.com" }, { - "name": "Stephen Coakley", - "email": "me@stephencoakley.com" + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" } ], - "description": "Parallel processing component for Amp.", - "homepage": "https://github.com/amphp/parallel", + "description": "A fiber-aware cache API based on Amp and Revolt.", + "homepage": "https://amphp.org/cache", + "support": { + "issues": "https://github.com/amphp/cache/issues", + "source": "https://github.com/amphp/cache/tree/v2.0.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-04-19T03:38:06+00:00" + }, + { + "name": "amphp/dns", + "version": "v2.2.0", + "source": { + "type": "git", + "url": "https://github.com/amphp/dns.git", + "reference": "758266b0ea7470e2e42cd098493bc6d6c7100cf7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/dns/zipball/758266b0ea7470e2e42cd098493bc6d6c7100cf7", + "reference": "758266b0ea7470e2e42cd098493bc6d6c7100cf7", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/parser": "^1", + "amphp/windows-registry": "^1.0.1", + "daverandom/libdns": "^2.0.2", + "ext-filter": "*", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.20" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Amp\\Dns\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Wright", + "email": "addr@daverandom.com" + }, + { + "name": "Daniel Lowrey", + "email": "rdlowrey@php.net" + }, + { + "name": "Bob Weinand", + "email": "bobwei9@hotmail.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + }, + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + } + ], + "description": "Async DNS resolution for Amp.", + "homepage": "https://github.com/amphp/dns", "keywords": [ + "amp", + "amphp", "async", - "asynchronous", - "concurrent", - "multi-processing", - "multi-threading" + "client", + "dns", + "resolve" ], "support": { - "issues": "https://github.com/amphp/parallel/issues", - "source": "https://github.com/amphp/parallel/tree/v1.4.3" + "issues": "https://github.com/amphp/dns/issues", + "source": "https://github.com/amphp/dns/tree/v2.2.0" }, "funding": [ { @@ -239,41 +314,51 @@ "type": "github" } ], - "time": "2023-03-23T08:04:23+00:00" + "time": "2024-06-02T19:54:12+00:00" }, { - "name": "amphp/parallel-functions", - "version": "v1.1.0", + "name": "amphp/parallel", + "version": "v2.3.0", "source": { "type": "git", - "url": "https://github.com/amphp/parallel-functions.git", - "reference": "04e92fcacfc921a56dfe12c23b3265e62593a7cb" + "url": "https://github.com/amphp/parallel.git", + "reference": "9777db1460d1535bc2a843840684fb1205225b87" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/parallel-functions/zipball/04e92fcacfc921a56dfe12c23b3265e62593a7cb", - "reference": "04e92fcacfc921a56dfe12c23b3265e62593a7cb", + "url": "https://api.github.com/repos/amphp/parallel/zipball/9777db1460d1535bc2a843840684fb1205225b87", + "reference": "9777db1460d1535bc2a843840684fb1205225b87", "shasum": "" }, "require": { - "amphp/amp": "^2.0.3", - "amphp/parallel": "^1.4", - "amphp/serialization": "^1.0", - "laravel/serializable-closure": "^1.0", - "php": ">=7.4" + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/cache": "^2", + "amphp/parser": "^1", + "amphp/pipeline": "^1", + "amphp/process": "^2", + "amphp/serialization": "^1", + "amphp/socket": "^2", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1" }, "require-dev": { - "amphp/php-cs-fixer-config": "v2.x-dev", - "amphp/phpunit-util": "^2.0", - "phpunit/phpunit": "^9.5.11" + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.18" }, "type": "library", "autoload": { "files": [ - "src/functions.php" + "src/Context/functions.php", + "src/Context/Internal/functions.php", + "src/Ipc/functions.php", + "src/Worker/functions.php" ], "psr-4": { - "Amp\\ParallelFunctions\\": "src" + "Amp\\Parallel\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -281,15 +366,31 @@ "MIT" ], "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, { "name": "Niklas Keller", "email": "me@kelunik.com" + }, + { + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" } ], - "description": "Parallel processing made simple.", + "description": "Parallel processing component for Amp.", + "homepage": "https://github.com/amphp/parallel", + "keywords": [ + "async", + "asynchronous", + "concurrent", + "multi-processing", + "multi-threading" + ], "support": { - "issues": "https://github.com/amphp/parallel-functions/issues", - "source": "https://github.com/amphp/parallel-functions/tree/v1.1.0" + "issues": "https://github.com/amphp/parallel/issues", + "source": "https://github.com/amphp/parallel/tree/v2.3.0" }, "funding": [ { @@ -297,7 +398,7 @@ "type": "github" } ], - "time": "2022-02-03T19:32:41+00:00" + "time": "2024-09-14T19:16:14+00:00" }, { "name": "amphp/parser", @@ -361,37 +462,107 @@ ], "time": "2024-03-21T19:16:53+00:00" }, + { + "name": "amphp/pipeline", + "version": "v1.2.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/pipeline.git", + "reference": "66c095673aa5b6e689e63b52d19e577459129ab3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/pipeline/zipball/66c095673aa5b6e689e63b52d19e577459129ab3", + "reference": "66c095673aa5b6e689e63b52d19e577459129ab3", + "shasum": "" + }, + "require": { + "amphp/amp": "^3", + "php": ">=8.1", + "revolt/event-loop": "^1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.18" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\Pipeline\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Asynchronous iterators and operators.", + "homepage": "https://amphp.org/pipeline", + "keywords": [ + "amp", + "amphp", + "async", + "io", + "iterator", + "non-blocking" + ], + "support": { + "issues": "https://github.com/amphp/pipeline/issues", + "source": "https://github.com/amphp/pipeline/tree/v1.2.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-07-04T00:56:47+00:00" + }, { "name": "amphp/process", - "version": "v1.1.7", + "version": "v2.0.3", "source": { "type": "git", "url": "https://github.com/amphp/process.git", - "reference": "1949d85b6d71af2818ff68144304a98495628f19" + "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/process/zipball/1949d85b6d71af2818ff68144304a98495628f19", - "reference": "1949d85b6d71af2818ff68144304a98495628f19", + "url": "https://api.github.com/repos/amphp/process/zipball/52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", + "reference": "52e08c09dec7511d5fbc1fb00d3e4e79fc77d58d", "shasum": "" }, "require": { - "amphp/amp": "^2", - "amphp/byte-stream": "^1.4", - "php": ">=7.1" + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/sync": "^2", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" }, "require-dev": { - "amphp/php-cs-fixer-config": "dev-master", - "amphp/phpunit-util": "^1", - "phpunit/phpunit": "^6" + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.4" }, "type": "library", "autoload": { "files": [ - "lib/functions.php" + "src/functions.php" ], "psr-4": { - "Amp\\Process\\": "lib" + "Amp\\Process\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -412,11 +583,11 @@ "email": "me@kelunik.com" } ], - "description": "Asynchronous process manager.", - "homepage": "https://github.com/amphp/process", + "description": "A fiber-aware process manager based on Amp and Revolt.", + "homepage": "https://amphp.org/process", "support": { "issues": "https://github.com/amphp/process/issues", - "source": "https://github.com/amphp/process/tree/v1.1.7" + "source": "https://github.com/amphp/process/tree/v2.0.3" }, "funding": [ { @@ -424,7 +595,7 @@ "type": "github" } ], - "time": "2024-04-19T03:00:28+00:00" + "time": "2024-04-19T03:13:44+00:00" }, { "name": "amphp/serialization", @@ -485,36 +656,46 @@ "time": "2020-03-25T21:39:07+00:00" }, { - "name": "amphp/sync", - "version": "v1.4.2", + "name": "amphp/socket", + "version": "v2.3.1", "source": { "type": "git", - "url": "https://github.com/amphp/sync.git", - "reference": "85ab06764f4f36d63b1356b466df6111cf4b89cf" + "url": "https://github.com/amphp/socket.git", + "reference": "58e0422221825b79681b72c50c47a930be7bf1e1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/amphp/sync/zipball/85ab06764f4f36d63b1356b466df6111cf4b89cf", - "reference": "85ab06764f4f36d63b1356b466df6111cf4b89cf", + "url": "https://api.github.com/repos/amphp/socket/zipball/58e0422221825b79681b72c50c47a930be7bf1e1", + "reference": "58e0422221825b79681b72c50c47a930be7bf1e1", "shasum": "" }, "require": { - "amphp/amp": "^2.2", - "php": ">=7.1" + "amphp/amp": "^3", + "amphp/byte-stream": "^2", + "amphp/dns": "^2", + "ext-openssl": "*", + "kelunik/certificate": "^1.1", + "league/uri": "^6.5 | ^7", + "league/uri-interfaces": "^2.3 | ^7", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" }, "require-dev": { - "amphp/php-cs-fixer-config": "dev-master", - "amphp/phpunit-util": "^1.1", - "phpunit/phpunit": "^9 || ^8 || ^7" + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "amphp/process": "^2", + "phpunit/phpunit": "^9", + "psalm/phar": "5.20" }, "type": "library", "autoload": { "files": [ "src/functions.php", - "src/ConcurrentIterator/functions.php" + "src/Internal/functions.php", + "src/SocketAddress/functions.php" ], "psr-4": { - "Amp\\Sync\\": "src" + "Amp\\Socket\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -522,27 +703,33 @@ "MIT" ], "authors": [ + { + "name": "Daniel Lowrey", + "email": "rdlowrey@gmail.com" + }, { "name": "Aaron Piotrowski", "email": "aaron@trowski.com" }, { - "name": "Stephen Coakley", - "email": "me@stephencoakley.com" + "name": "Niklas Keller", + "email": "me@kelunik.com" } ], - "description": "Mutex, Semaphore, and other synchronization tools for Amp.", - "homepage": "https://github.com/amphp/sync", + "description": "Non-blocking socket connection / server implementations based on Amp and Revolt.", + "homepage": "https://github.com/amphp/socket", "keywords": [ + "amp", "async", - "asynchronous", - "mutex", - "semaphore", - "synchronization" + "encryption", + "non-blocking", + "sockets", + "tcp", + "tls" ], "support": { - "issues": "https://github.com/amphp/sync/issues", - "source": "https://github.com/amphp/sync/tree/v1.4.2" + "issues": "https://github.com/amphp/socket/issues", + "source": "https://github.com/amphp/socket/tree/v2.3.1" }, "funding": [ { @@ -550,44 +737,42 @@ "type": "github" } ], - "time": "2021-10-25T18:29:10+00:00" + "time": "2024-04-21T14:33:03+00:00" }, { - "name": "composer/package-versions-deprecated", - "version": "1.11.99.5", + "name": "amphp/sync", + "version": "v2.3.0", "source": { "type": "git", - "url": "https://github.com/composer/package-versions-deprecated.git", - "reference": "b4f54f74ef3453349c24a845d22392cd31e65f1d" + "url": "https://github.com/amphp/sync.git", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/composer/package-versions-deprecated/zipball/b4f54f74ef3453349c24a845d22392cd31e65f1d", - "reference": "b4f54f74ef3453349c24a845d22392cd31e65f1d", + "url": "https://api.github.com/repos/amphp/sync/zipball/217097b785130d77cfcc58ff583cf26cd1770bf1", + "reference": "217097b785130d77cfcc58ff583cf26cd1770bf1", "shasum": "" }, "require": { - "composer-plugin-api": "^1.1.0 || ^2.0", - "php": "^7 || ^8" - }, - "replace": { - "ocramius/package-versions": "1.11.99" + "amphp/amp": "^3", + "amphp/pipeline": "^1", + "amphp/serialization": "^1", + "php": ">=8.1", + "revolt/event-loop": "^1 || ^0.2" }, "require-dev": { - "composer/composer": "^1.9.3 || ^2.0@dev", - "ext-zip": "^1.13", - "phpunit/phpunit": "^6.5 || ^7" - }, - "type": "composer-plugin", - "extra": { - "class": "PackageVersions\\Installer", - "branch-alias": { - "dev-master": "1.x-dev" - } + "amphp/php-cs-fixer-config": "^2", + "amphp/phpunit-util": "^3", + "phpunit/phpunit": "^9", + "psalm/phar": "5.23" }, + "type": "library", "autoload": { + "files": [ + "src/functions.php" + ], "psr-4": { - "PackageVersions\\": "src/PackageVersions" + "Amp\\Sync\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -596,34 +781,90 @@ ], "authors": [ { - "name": "Marco Pivetta", - "email": "ocramius@gmail.com" + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" }, { - "name": "Jordi Boggiano", - "email": "j.boggiano@seld.be" + "name": "Stephen Coakley", + "email": "me@stephencoakley.com" } ], - "description": "Composer plugin that provides efficient querying for installed package versions (no runtime IO)", + "description": "Non-blocking synchronization primitives for PHP based on Amp and Revolt.", + "homepage": "https://github.com/amphp/sync", + "keywords": [ + "async", + "asynchronous", + "mutex", + "semaphore", + "synchronization" + ], "support": { - "issues": "https://github.com/composer/package-versions-deprecated/issues", - "source": "https://github.com/composer/package-versions-deprecated/tree/1.11.99.5" + "issues": "https://github.com/amphp/sync/issues", + "source": "https://github.com/amphp/sync/tree/v2.3.0" }, "funding": [ { - "url": "https://packagist.com", - "type": "custom" - }, - { - "url": "https://github.com/composer", + "url": "https://github.com/amphp", "type": "github" - }, + } + ], + "time": "2024-08-03T19:31:26+00:00" + }, + { + "name": "amphp/windows-registry", + "version": "v1.0.1", + "source": { + "type": "git", + "url": "https://github.com/amphp/windows-registry.git", + "reference": "0d569e8f256cca974e3842b6e78b4e434bf98306" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/amphp/windows-registry/zipball/0d569e8f256cca974e3842b6e78b4e434bf98306", + "reference": "0d569e8f256cca974e3842b6e78b4e434bf98306", + "shasum": "" + }, + "require": { + "amphp/byte-stream": "^2", + "amphp/process": "^2", + "php": ">=8.1" + }, + "require-dev": { + "amphp/php-cs-fixer-config": "^2", + "psalm/phar": "^5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Amp\\WindowsRegistry\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ { - "url": "https://tidelift.com/funding/github/packagist/composer/composer", - "type": "tidelift" + "name": "Niklas Keller", + "email": "me@kelunik.com" } ], - "time": "2022-01-17T14:14:24+00:00" + "description": "Windows Registry Reader.", + "support": { + "issues": "https://github.com/amphp/windows-registry/issues", + "source": "https://github.com/amphp/windows-registry/tree/v1.0.1" + }, + "funding": [ + { + "url": "https://github.com/amphp", + "type": "github" + } + ], + "time": "2024-01-30T23:01:51+00:00" }, { "name": "composer/pcre", @@ -851,31 +1092,73 @@ ], "time": "2024-05-06T16:37:16+00:00" }, + { + "name": "daverandom/libdns", + "version": "v2.1.0", + "source": { + "type": "git", + "url": "https://github.com/DaveRandom/LibDNS.git", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DaveRandom/LibDNS/zipball/b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "reference": "b84c94e8fe6b7ee4aecfe121bfe3b6177d303c8a", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "php": ">=7.1" + }, + "suggest": { + "ext-intl": "Required for IDN support" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "LibDNS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "DNS protocol implementation written in pure PHP", + "keywords": [ + "dns" + ], + "support": { + "issues": "https://github.com/DaveRandom/LibDNS/issues", + "source": "https://github.com/DaveRandom/LibDNS/tree/v2.1.0" + }, + "time": "2024-04-12T12:12:48+00:00" + }, { "name": "doctrine/deprecations", - "version": "1.1.3", + "version": "1.1.4", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab" + "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab", - "reference": "dfbaa3c2d2e9a9df1118213f3b8b0c597bb99fab", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/31610dbb31faa98e6b5447b62340826f54fbc4e9", + "reference": "31610dbb31faa98e6b5447b62340826f54fbc4e9", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "require-dev": { - "doctrine/coding-standard": "^9", - "phpstan/phpstan": "1.4.10 || 1.10.15", - "phpstan/phpstan-phpunit": "^1.0", + "doctrine/coding-standard": "^9 || ^12", + "phpstan/phpstan": "1.4.10 || 2.0.3", + "phpstan/phpstan-phpunit": "^1.0 || ^2", "phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", - "psalm/plugin-phpunit": "0.18.4", - "psr/log": "^1 || ^2 || ^3", - "vimeo/psalm": "4.30.0 || 5.12.0" + "psr/log": "^1 || ^2 || ^3" }, "suggest": { "psr/log": "Allows logging deprecations via PSR-3 logger implementation" @@ -883,7 +1166,7 @@ "type": "library", "autoload": { "psr-4": { - "Doctrine\\Deprecations\\": "lib/Doctrine/Deprecations" + "Doctrine\\Deprecations\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -894,50 +1177,51 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.3" + "source": "https://github.com/doctrine/deprecations/tree/1.1.4" }, - "time": "2024-01-30T19:34:25+00:00" + "time": "2024-12-07T21:18:45+00:00" }, { "name": "fidry/console", - "version": "0.5.5", + "version": "0.6.10", "source": { "type": "git", "url": "https://github.com/theofidry/console.git", - "reference": "bc1fe03f600c63f12ec0a39c6b746c1a1fb77bf7" + "reference": "a681ea3aa7f5c0c78cd437250f64b13d2818c95d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/theofidry/console/zipball/bc1fe03f600c63f12ec0a39c6b746c1a1fb77bf7", - "reference": "bc1fe03f600c63f12ec0a39c6b746c1a1fb77bf7", + "url": "https://api.github.com/repos/theofidry/console/zipball/a681ea3aa7f5c0c78cd437250f64b13d2818c95d", + "reference": "a681ea3aa7f5c0c78cd437250f64b13d2818c95d", "shasum": "" }, "require": { - "php": "^7.4.0 || ^8.0.0", - "symfony/console": "^4.4 || ^5.4 || ^6.1", - "symfony/event-dispatcher-contracts": "^1.0 || ^2.5 || ^3.0", - "symfony/service-contracts": "^1.0 || ^2.5 || ^3.0", - "thecodingmachine/safe": "^1.3 || ^2.0", + "php": "^8.2", + "psr/log": "^3.0", + "symfony/console": "^6.4 || ^7.0", + "symfony/deprecation-contracts": "^3.4", + "symfony/event-dispatcher-contracts": "^2.5 || ^3.0", + "symfony/service-contracts": "^2.5 || ^3.0", + "thecodingmachine/safe": "^2.0", "webmozart/assert": "^1.11" }, "conflict": { - "symfony/dependency-injection": "<5.3.0", - "symfony/framework-bundle": "<5.3.0", - "symfony/http-kernel": "<5.3.0" + "symfony/dependency-injection": "<6.4.0", + "symfony/framework-bundle": "<6.4.0", + "symfony/http-kernel": "<6.4.0" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.4", - "composer/semver": "^3.3", - "ergebnis/composer-normalize": "^2.28", - "infection/infection": "^0.26", - "phpspec/prophecy-phpunit": "^2.0", - "phpunit/phpunit": "^9.4.3", - "symfony/dependency-injection": "^4.4 || ^5.4 || ^6.1", - "symfony/framework-bundle": "^4.4 || ^5.4 || ^6.1", - "symfony/http-kernel": "^4.4 || ^5.4 || ^6.1", - "symfony/phpunit-bridge": "^4.4.47 || ^5.4 || ^6.0", - "symfony/yaml": "^4.4 || ^5.4 || ^6.1", - "webmozarts/strict-phpunit": "^7.3" + "bamarni/composer-bin-plugin": "^1.8.2", + "composer/semver": "^3.3.2", + "ergebnis/composer-normalize": "^2.33", + "fidry/makefile": "^0.2.1 || ^1.0.0", + "infection/infection": "^0.28", + "phpunit/phpunit": "^10.2", + "symfony/dependency-injection": "^6.4", + "symfony/flex": "^2.4.0", + "symfony/framework-bundle": "^6.4", + "symfony/http-kernel": "^6.4", + "symfony/yaml": "^6.4 || ^7.0" }, "type": "library", "extra": { @@ -972,7 +1256,74 @@ ], "support": { "issues": "https://github.com/theofidry/console/issues", - "source": "https://github.com/theofidry/console/tree/0.5.5" + "source": "https://github.com/theofidry/console/tree/0.6.10" + }, + "funding": [ + { + "url": "https://github.com/theofidry", + "type": "github" + } + ], + "time": "2024-04-23T08:36:33+00:00" + }, + { + "name": "fidry/filesystem", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/theofidry/filesystem.git", + "reference": "8303225d289da1c434f6009743fbe9aad852de0c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theofidry/filesystem/zipball/8303225d289da1c434f6009743fbe9aad852de0c", + "reference": "8303225d289da1c434f6009743fbe9aad852de0c", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/filesystem": "^6.4 || ^7.0", + "thecodingmachine/safe": "^2.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4", + "ergebnis/composer-normalize": "^2.28", + "infection/infection": ">=0.26", + "phpunit/phpunit": "^10.3", + "symfony/finder": "^6.4 || ^7.0" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Fidry\\FileSystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Théo Fidry", + "email": "theo.fidry@gmail.com" + } + ], + "description": "Symfony Filesystem with a few more utilities.", + "keywords": [ + "filesystem" + ], + "support": { + "issues": "https://github.com/theofidry/filesystem/issues", + "source": "https://github.com/theofidry/filesystem/tree/1.2.1" }, "funding": [ { @@ -980,52 +1331,65 @@ "type": "github" } ], - "time": "2022-12-18T10:49:34+00:00" + "time": "2023-12-10T13:29:09+00:00" }, { "name": "humbug/box", - "version": "3.16.0", + "version": "4.6.2", "source": { "type": "git", "url": "https://github.com/box-project/box.git", - "reference": "adb282ad00a20438fc881f8ec9207ed7446243b9" + "reference": "29c3585c64a16d17df97699dd9e0291591a266a3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/box-project/box/zipball/adb282ad00a20438fc881f8ec9207ed7446243b9", - "reference": "adb282ad00a20438fc881f8ec9207ed7446243b9", + "url": "https://api.github.com/repos/box-project/box/zipball/29c3585c64a16d17df97699dd9e0291591a266a3", + "reference": "29c3585c64a16d17df97699dd9e0291591a266a3", "shasum": "" }, "require": { - "amphp/parallel-functions": "^1.1", + "amphp/parallel": "^2.0", "composer-plugin-api": "^2.2", - "composer/semver": "^3.2", - "composer/xdebug-handler": "^2.0 || ^3.0", + "composer/semver": "^3.3.2", + "composer/xdebug-handler": "^3.0.3", + "ext-iconv": "*", + "ext-mbstring": "*", "ext-phar": "*", - "humbug/php-scoper": "^0.17", - "justinrainbow/json-schema": "^5.2.9", - "laravel/serializable-closure": "^1.0", - "nikic/iter": "^2.0", - "nikic/php-parser": "^4.2", - "paragonie/pharaoh": "^0.6", - "php": "^7.4 || ^8.0", - "phpdocumentor/reflection-docblock": "^5.2", - "psr/log": "^1.0", - "seld/jsonlint": "^1.7", - "symfony/console": "^4.3.5 || ^5.2", - "symfony/filesystem": "^4.4 || ^5.2", - "symfony/finder": "^4.4 || ^5.2", - "symfony/process": "^4.4 || ^5.2", - "symfony/var-dumper": "^4.4 || ^5.2", - "webmozart/assert": "^1.9", - "webmozart/path-util": "^2.3" + "fidry/console": "^0.6.0", + "fidry/filesystem": "^1.2.1", + "humbug/php-scoper": "^0.18.6", + "justinrainbow/json-schema": "^5.2.12", + "nikic/iter": "^2.2", + "php": "^8.2", + "phpdocumentor/reflection-docblock": "^5.4", + "phpdocumentor/type-resolver": "^1.7", + "psr/log": "^3.0", + "sebastian/diff": "^5.0", + "seld/jsonlint": "^1.10.2", + "seld/phar-utils": "^1.2", + "symfony/finder": "^6.4.0 || ^7.0.0", + "symfony/polyfill-iconv": "^1.28", + "symfony/polyfill-mbstring": "^1.28", + "symfony/process": "^6.4.0 || ^7.0.0", + "symfony/var-dumper": "^6.4.0 || ^7.0.0", + "thecodingmachine/safe": "^2.5", + "webmozart/assert": "^1.11" + }, + "replace": { + "symfony/polyfill-php80": "*", + "symfony/polyfill-php81": "*", + "symfony/polyfill-php82": "*" }, "require-dev": { - "bamarni/composer-bin-plugin": "^1.3", - "mikey179/vfsstream": "^1.6", - "phpspec/prophecy-phpunit": "^2.0", - "phpunit/phpunit": "^9.0", - "symfony/phpunit-bridge": "^4.2 || ^5.0" + "bamarni/composer-bin-plugin": "^1.8.2", + "ergebnis/composer-normalize": "^2.29", + "ext-xml": "*", + "fidry/makefile": "^1.0.1", + "mikey179/vfsstream": "^1.6.11", + "phpspec/prophecy": "^1.18", + "phpspec/prophecy-phpunit": "^2.1.0", + "phpunit/phpunit": "^10.5.2", + "symfony/yaml": "^6.4.0 || ^7.0.0" }, "suggest": { "ext-openssl": "To accelerate private key generation." @@ -1035,24 +1399,24 @@ ], "type": "library", "extra": { - "branch-alias": { - "dev-master": "3.x-dev" - }, "bamarni-bin": { - "bin-links": false + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "4.x-dev" } }, "autoload": { "files": [ - "src/FileSystem/file_system.php", - "src/consts.php", "src/functions.php" ], "psr-4": { "KevinGH\\Box\\": "src" }, "exclude-from-classmap": [ - "/Test/" + "/Test/", + "vendor/humbug/php-scoper/vendor-hotfix" ] }, "notification-url": "https://packagist.org/downloads/", @@ -1076,45 +1440,44 @@ ], "support": { "issues": "https://github.com/box-project/box/issues", - "source": "https://github.com/box-project/box/tree/3.16.0" + "source": "https://github.com/box-project/box/tree/4.6.2" }, - "time": "2022-02-13T23:10:13+00:00" + "time": "2024-04-23T19:33:48+00:00" }, { "name": "humbug/php-scoper", - "version": "0.17.5", + "version": "0.18.15", "source": { "type": "git", "url": "https://github.com/humbug/php-scoper.git", - "reference": "f67ae1e5360259911d6c4be871e4aeb4e6661541" + "reference": "79b2b4e0fbc1d1ef6ae99c4e078137b42bd43d19" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/humbug/php-scoper/zipball/f67ae1e5360259911d6c4be871e4aeb4e6661541", - "reference": "f67ae1e5360259911d6c4be871e4aeb4e6661541", + "url": "https://api.github.com/repos/humbug/php-scoper/zipball/79b2b4e0fbc1d1ef6ae99c4e078137b42bd43d19", + "reference": "79b2b4e0fbc1d1ef6ae99c4e078137b42bd43d19", "shasum": "" }, "require": { - "composer/package-versions-deprecated": "^1.8", - "fidry/console": "^0.5.0", - "jetbrains/phpstorm-stubs": "^v2022.1", - "nikic/php-parser": "^4.12", - "php": "^7.4 || ^8.0", - "symfony/console": "^5.2 || ^6.0", - "symfony/filesystem": "^5.2 || ^6.0", - "symfony/finder": "^5.2 || ^6.0", - "symfony/polyfill-php80": "^1.23", - "symfony/polyfill-php81": "^1.24", - "thecodingmachine/safe": "^1.3 || ^2.0" - }, - "replace": { - "symfony/polyfill-php73": "*" + "fidry/console": "^0.6.10", + "fidry/filesystem": "^1.1", + "jetbrains/phpstorm-stubs": "^2024.1", + "nikic/php-parser": "^5.0", + "php": "^8.2", + "symfony/console": "^6.4 || ^7.0", + "symfony/filesystem": "^6.4 || ^7.0", + "symfony/finder": "^6.4 || ^7.0", + "symfony/var-dumper": "^7.1", + "thecodingmachine/safe": "^2.0" }, "require-dev": { "bamarni/composer-bin-plugin": "^1.1", - "humbug/box": "^3.16.0 || ^4.0", + "ergebnis/composer-normalize": "^2.28", + "fidry/makefile": "^1.0", + "humbug/box": "^4.6.2", "phpspec/prophecy-phpunit": "^2.0", - "phpunit/phpunit": "^9.0" + "phpunit/phpunit": "^10.0", + "symfony/yaml": "^6.4 || ^7.0" }, "bin": [ "bin/php-scoper" @@ -1122,7 +1485,8 @@ "type": "library", "extra": { "bamarni-bin": { - "bin-links": false + "bin-links": false, + "forward-command": false }, "branch-alias": { "dev-master": "1.0-dev" @@ -1134,7 +1498,10 @@ ], "psr-4": { "Humbug\\PhpScoper\\": "src/" - } + }, + "classmap": [ + "vendor-hotfix/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -1157,30 +1524,29 @@ "description": "Prefixes all PHP namespaces in a file or directory.", "support": { "issues": "https://github.com/humbug/php-scoper/issues", - "source": "https://github.com/humbug/php-scoper/tree/0.17.5" + "source": "https://github.com/humbug/php-scoper/tree/0.18.15" }, - "time": "2022-06-26T22:25:11+00:00" + "time": "2024-09-02T13:35:10+00:00" }, { "name": "jetbrains/phpstorm-stubs", - "version": "v2022.3", + "version": "v2024.2", "source": { "type": "git", "url": "https://github.com/JetBrains/phpstorm-stubs.git", - "reference": "6b568c153cea002dc6fad96285c3063d07cab18d" + "reference": "cf7e447ddfa7f0cbab0c1dd38392f0cb05f9881c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/6b568c153cea002dc6fad96285c3063d07cab18d", - "reference": "6b568c153cea002dc6fad96285c3063d07cab18d", + "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/cf7e447ddfa7f0cbab0c1dd38392f0cb05f9881c", + "reference": "cf7e447ddfa7f0cbab0c1dd38392f0cb05f9881c", "shasum": "" }, "require-dev": { - "friendsofphp/php-cs-fixer": "@stable", - "nikic/php-parser": "@stable", - "php": "^8.0", - "phpdocumentor/reflection-docblock": "@stable", - "phpunit/phpunit": "@stable" + "friendsofphp/php-cs-fixer": "v3.46.0", + "nikic/php-parser": "v5.0.0", + "phpdocumentor/reflection-docblock": "5.3.0", + "phpunit/phpunit": "10.5.5" }, "type": "library", "autoload": { @@ -1205,9 +1571,9 @@ "type" ], "support": { - "source": "https://github.com/JetBrains/phpstorm-stubs/tree/v2022.3" + "source": "https://github.com/JetBrains/phpstorm-stubs/tree/v2024.2" }, - "time": "2022-10-17T09:21:37+00:00" + "time": "2024-06-17T19:18:18+00:00" }, { "name": "justinrainbow/json-schema", @@ -1275,28 +1641,26 @@ "time": "2024-07-06T21:00:26+00:00" }, { - "name": "laravel/serializable-closure", - "version": "v1.3.7", + "name": "kelunik/certificate", + "version": "v1.1.3", "source": { "type": "git", - "url": "https://github.com/laravel/serializable-closure.git", - "reference": "4f48ade902b94323ca3be7646db16209ec76be3d" + "url": "https://github.com/kelunik/certificate.git", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/4f48ade902b94323ca3be7646db16209ec76be3d", - "reference": "4f48ade902b94323ca3be7646db16209ec76be3d", + "url": "https://api.github.com/repos/kelunik/certificate/zipball/7e00d498c264d5eb4f78c69f41c8bd6719c0199e", + "reference": "7e00d498c264d5eb4f78c69f41c8bd6719c0199e", "shasum": "" }, "require": { - "php": "^7.3|^8.0" + "ext-openssl": "*", + "php": ">=7.0" }, "require-dev": { - "illuminate/support": "^8.0|^9.0|^10.0|^11.0", - "nesbot/carbon": "^2.61|^3.0", - "pestphp/pest": "^1.21.3", - "phpstan/phpstan": "^1.8.2", - "symfony/var-dumper": "^5.4.11|^6.2.0|^7.0.0" + "amphp/php-cs-fixer-config": "^2", + "phpunit/phpunit": "^6 | 7 | ^8 | ^9" }, "type": "library", "extra": { @@ -1306,7 +1670,7 @@ }, "autoload": { "psr-4": { - "Laravel\\SerializableClosure\\": "src/" + "Kelunik\\Certificate\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -1315,25 +1679,198 @@ ], "authors": [ { - "name": "Taylor Otwell", - "email": "taylor@laravel.com" - }, + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Access certificate details and transform between different formats.", + "keywords": [ + "DER", + "certificate", + "certificates", + "openssl", + "pem", + "x509" + ], + "support": { + "issues": "https://github.com/kelunik/certificate/issues", + "source": "https://github.com/kelunik/certificate/tree/v1.1.3" + }, + "time": "2023-02-03T21:26:53+00:00" + }, + { + "name": "league/uri", + "version": "7.5.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "81fb5145d2644324614cc532b28efd0215bda430" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/81fb5145d2644324614cc532b28efd0215bda430", + "reference": "81fb5145d2644324614cc532b28efd0215bda430", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.5", + "php": "^8.1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "jeremykendall/php-domain-parser": "to resolve Public Suffix and Top Level Domain", + "league/uri-components": "Needed to easily manipulate URI objects components", + "php-64bit": "to improve IPV4 host parsing", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.5.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2024-12-08T08:40:02+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.5.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", + "reference": "08cfc6c4f3d811584fb09c37e2849e6a7f9b0742", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-factory": "^1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ { - "name": "Nuno Maduro", - "email": "nuno@laravel.com" + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" } ], - "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "description": "Common interfaces and classes for URI representation and interaction", + "homepage": "https://uri.thephpleague.com", "keywords": [ - "closure", - "laravel", - "serializable" + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" ], "support": { - "issues": "https://github.com/laravel/serializable-closure/issues", - "source": "https://github.com/laravel/serializable-closure" + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.5.0" }, - "time": "2024-11-14T18:34:49+00:00" + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2024-12-08T08:18:47+00:00" }, { "name": "nikic/iter", @@ -1389,25 +1926,27 @@ }, { "name": "nikic/php-parser", - "version": "v4.19.4", + "version": "v5.3.1", "source": { "type": "git", "url": "https://github.com/nikic/PHP-Parser.git", - "reference": "715f4d25e225bc47b293a8b997fe6ce99bf987d2" + "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/715f4d25e225bc47b293a8b997fe6ce99bf987d2", - "reference": "715f4d25e225bc47b293a8b997fe6ce99bf987d2", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/8eea230464783aa9671db8eea6f8c6ac5285794b", + "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b", "shasum": "" }, "require": { + "ext-ctype": "*", + "ext-json": "*", "ext-tokenizer": "*", - "php": ">=7.1" + "php": ">=7.4" }, "require-dev": { "ircmaxell/php-yacc": "^0.0.7", - "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0" + "phpunit/phpunit": "^9.0" }, "bin": [ "bin/php-parse" @@ -1415,7 +1954,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "4.9-dev" + "dev-master": "5.0-dev" } }, "autoload": { @@ -1439,300 +1978,36 @@ ], "support": { "issues": "https://github.com/nikic/PHP-Parser/issues", - "source": "https://github.com/nikic/PHP-Parser/tree/v4.19.4" + "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.1" }, - "time": "2024-09-29T15:01:53+00:00" + "time": "2024-10-08T18:51:32+00:00" }, { - "name": "paragonie/constant_time_encoding", - "version": "v2.7.0", + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", "source": { "type": "git", - "url": "https://github.com/paragonie/constant_time_encoding.git", - "reference": "52a0d99e69f56b9ec27ace92ba56897fe6993105" + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/52a0d99e69f56b9ec27ace92ba56897fe6993105", - "reference": "52a0d99e69f56b9ec27ace92ba56897fe6993105", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", "shasum": "" }, "require": { - "php": "^7|^8" - }, - "require-dev": { - "phpunit/phpunit": "^6|^7|^8|^9", - "vimeo/psalm": "^1|^2|^3|^4" + "php": "^7.2 || ^8.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, "autoload": { "psr-4": { - "ParagonIE\\ConstantTime\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Paragon Initiative Enterprises", - "email": "security@paragonie.com", - "homepage": "https://paragonie.com", - "role": "Maintainer" - }, - { - "name": "Steve 'Sc00bz' Thomas", - "email": "steve@tobtu.com", - "homepage": "https://www.tobtu.com", - "role": "Original Developer" - } - ], - "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)", - "keywords": [ - "base16", - "base32", - "base32_decode", - "base32_encode", - "base64", - "base64_decode", - "base64_encode", - "bin2hex", - "encoding", - "hex", - "hex2bin", - "rfc4648" - ], - "support": { - "email": "info@paragonie.com", - "issues": "https://github.com/paragonie/constant_time_encoding/issues", - "source": "https://github.com/paragonie/constant_time_encoding" - }, - "time": "2024-05-08T12:18:48+00:00" - }, - { - "name": "paragonie/pharaoh", - "version": "v0.6.1", - "source": { - "type": "git", - "url": "https://github.com/paragonie/pharaoh.git", - "reference": "d661fa3e6b46429b9d5b21d974e35a6ef62e7220" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/paragonie/pharaoh/zipball/d661fa3e6b46429b9d5b21d974e35a6ef62e7220", - "reference": "d661fa3e6b46429b9d5b21d974e35a6ef62e7220", - "shasum": "" - }, - "require": { - "paragonie/constant_time_encoding": "^2|^3", - "paragonie/sodium_compat": "^1.3", - "php": "^7.1|^8", - "ulrichsg/getopt-php": "^3" - }, - "require-dev": { - "vimeo/psalm": "^1|^2|^3|^4" - }, - "bin": [ - "pharaoh" - ], - "type": "library", - "autoload": { - "psr-4": { - "ParagonIE\\Pharaoh\\": "src/Pharaoh/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Scott Arciszewski", - "email": "scott@paragonie.com", - "homepage": "https://paragonie.com", - "role": "Developer" - } - ], - "description": "Compare PHARs from the Command Line", - "keywords": [ - "auditing", - "diff", - "phar", - "security", - "tool", - "utility" - ], - "support": { - "email": "info@paragonie.com", - "issues": "https://github.com/paragonie/pharaoh/issues", - "source": "https://github.com/paragonie/pharaoh" - }, - "abandoned": "humbug/box", - "time": "2024-05-08T16:20:23+00:00" - }, - { - "name": "paragonie/random_compat", - "version": "v9.99.100", - "source": { - "type": "git", - "url": "https://github.com/paragonie/random_compat.git", - "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a", - "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a", - "shasum": "" - }, - "require": { - "php": ">= 7" - }, - "require-dev": { - "phpunit/phpunit": "4.*|5.*", - "vimeo/psalm": "^1" - }, - "suggest": { - "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." - }, - "type": "library", - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Paragon Initiative Enterprises", - "email": "security@paragonie.com", - "homepage": "https://paragonie.com" - } - ], - "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", - "keywords": [ - "csprng", - "polyfill", - "pseudorandom", - "random" - ], - "support": { - "email": "info@paragonie.com", - "issues": "https://github.com/paragonie/random_compat/issues", - "source": "https://github.com/paragonie/random_compat" - }, - "time": "2020-10-15T08:29:30+00:00" - }, - { - "name": "paragonie/sodium_compat", - "version": "v1.21.1", - "source": { - "type": "git", - "url": "https://github.com/paragonie/sodium_compat.git", - "reference": "bb312875dcdd20680419564fe42ba1d9564b9e37" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/paragonie/sodium_compat/zipball/bb312875dcdd20680419564fe42ba1d9564b9e37", - "reference": "bb312875dcdd20680419564fe42ba1d9564b9e37", - "shasum": "" - }, - "require": { - "paragonie/random_compat": ">=1", - "php": "^5.2.4|^5.3|^5.4|^5.5|^5.6|^7|^8" - }, - "require-dev": { - "phpunit/phpunit": "^3|^4|^5|^6|^7|^8|^9" - }, - "suggest": { - "ext-libsodium": "PHP < 7.0: Better performance, password hashing (Argon2i), secure memory management (memzero), and better security.", - "ext-sodium": "PHP >= 7.0: Better performance, password hashing (Argon2i), secure memory management (memzero), and better security." - }, - "type": "library", - "autoload": { - "files": [ - "autoload.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "ISC" - ], - "authors": [ - { - "name": "Paragon Initiative Enterprises", - "email": "security@paragonie.com" - }, - { - "name": "Frank Denis", - "email": "jedisct1@pureftpd.org" - } - ], - "description": "Pure PHP implementation of libsodium; uses the PHP extension if it exists", - "keywords": [ - "Authentication", - "BLAKE2b", - "ChaCha20", - "ChaCha20-Poly1305", - "Chapoly", - "Curve25519", - "Ed25519", - "EdDSA", - "Edwards-curve Digital Signature Algorithm", - "Elliptic Curve Diffie-Hellman", - "Poly1305", - "Pure-PHP cryptography", - "RFC 7748", - "RFC 8032", - "Salpoly", - "Salsa20", - "X25519", - "XChaCha20-Poly1305", - "XSalsa20-Poly1305", - "Xchacha20", - "Xsalsa20", - "aead", - "cryptography", - "ecdh", - "elliptic curve", - "elliptic curve cryptography", - "encryption", - "libsodium", - "php", - "public-key cryptography", - "secret-key cryptography", - "side-channel resistant" - ], - "support": { - "issues": "https://github.com/paragonie/sodium_compat/issues", - "source": "https://github.com/paragonie/sodium_compat/tree/v1.21.1" - }, - "time": "2024-04-22T22:05:04+00:00" - }, - { - "name": "phpdocumentor/reflection-common", - "version": "2.2.0", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/ReflectionCommon.git", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-2.x": "2.x-dev" - } - }, - "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": "src/" + "phpDocumentor\\Reflection\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -1762,16 +2037,16 @@ }, { "name": "phpdocumentor/reflection-docblock", - "version": "5.6.0", + "version": "5.6.1", "source": { "type": "git", "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "f3558a4c23426d12bffeaab463f8a8d8b681193c" + "reference": "e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/f3558a4c23426d12bffeaab463f8a8d8b681193c", - "reference": "f3558a4c23426d12bffeaab463f8a8d8b681193c", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8", + "reference": "e5e784149a09bd69d9a5e3b01c5cbd2e2bd653d8", "shasum": "" }, "require": { @@ -1820,9 +2095,9 @@ "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", "support": { "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.0" + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.1" }, - "time": "2024-11-12T11:25:25+00:00" + "time": "2024-12-07T09:39:29+00:00" }, { "name": "phpdocumentor/type-resolver", @@ -1931,22 +2206,27 @@ }, { "name": "psr/container", - "version": "1.1.2", + "version": "2.0.2", "source": { "type": "git", "url": "https://github.com/php-fig/container.git", - "reference": "513e0666f7216c7459170d56df27dfcefe1689ea" + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/container/zipball/513e0666f7216c7459170d56df27dfcefe1689ea", - "reference": "513e0666f7216c7459170d56df27dfcefe1689ea", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", "shasum": "" }, "require": { "php": ">=7.4.0" }, "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, "autoload": { "psr-4": { "Psr\\Container\\": "src/" @@ -1973,9 +2253,9 @@ ], "support": { "issues": "https://github.com/php-fig/container/issues", - "source": "https://github.com/php-fig/container/tree/1.1.2" + "source": "https://github.com/php-fig/container/tree/2.0.2" }, - "time": "2021-11-05T16:50:12+00:00" + "time": "2021-11-05T16:47:00+00:00" }, { "name": "psr/event-dispatcher", @@ -2027,32 +2307,140 @@ }, "time": "2019-01-08T18:20:26+00:00" }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, { "name": "psr/log", - "version": "1.1.4", + "version": "3.0.2", "source": { "type": "git", "url": "https://github.com/php-fig/log.git", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", - "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": ">=8.0.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "1.1.x-dev" + "dev-master": "3.x-dev" } }, "autoload": { "psr-4": { - "Psr\\Log\\": "Psr/Log/" + "Psr\\Log\\": "src" } }, "notification-url": "https://packagist.org/downloads/", @@ -2073,9 +2461,148 @@ "psr-3" ], "support": { - "source": "https://github.com/php-fig/log/tree/1.1.4" + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "revolt/event-loop", + "version": "v1.0.6", + "source": { + "type": "git", + "url": "https://github.com/revoltphp/event-loop.git", + "reference": "25de49af7223ba039f64da4ae9a28ec2d10d0254" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/revoltphp/event-loop/zipball/25de49af7223ba039f64da4ae9a28ec2d10d0254", + "reference": "25de49af7223ba039f64da4ae9a28ec2d10d0254", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "ext-json": "*", + "jetbrains/phpstorm-stubs": "^2019.3", + "phpunit/phpunit": "^9", + "psalm/phar": "^5.15" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Revolt\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aaron Piotrowski", + "email": "aaron@trowski.com" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "ceesjank@gmail.com" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + }, + { + "name": "Niklas Keller", + "email": "me@kelunik.com" + } + ], + "description": "Rock-solid event loop for concurrent PHP applications.", + "keywords": [ + "async", + "asynchronous", + "concurrency", + "event", + "event-loop", + "non-blocking", + "scheduler" + ], + "support": { + "issues": "https://github.com/revoltphp/event-loop/issues", + "source": "https://github.com/revoltphp/event-loop/tree/v1.0.6" + }, + "time": "2023-11-30T05:34:44+00:00" + }, + { + "name": "sebastian/diff", + "version": "5.1.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/c41e007b4b62af48218231d6c2275e4c9b975b2e", + "reference": "c41e007b4b62af48218231d6c2275e4c9b975b2e", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "symfony/process": "^6.4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/5.1.1" }, - "time": "2021-05-03T11:20:27+00:00" + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2024-03-02T07:15:17+00:00" }, { "name": "seld/jsonlint", @@ -2118,77 +2645,119 @@ "homepage": "https://seld.be" } ], - "description": "JSON Linter", + "description": "JSON Linter", + "keywords": [ + "json", + "linter", + "parser", + "validator" + ], + "support": { + "issues": "https://github.com/Seldaek/jsonlint/issues", + "source": "https://github.com/Seldaek/jsonlint/tree/1.11.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/seld/jsonlint", + "type": "tidelift" + } + ], + "time": "2024-07-11T14:55:45+00:00" + }, + { + "name": "seld/phar-utils", + "version": "1.2.1", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/phar-utils.git", + "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/phar-utils/zipball/ea2f4014f163c1be4c601b9b7bd6af81ba8d701c", + "reference": "ea2f4014f163c1be4c601b9b7bd6af81ba8d701c", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Seld\\PharUtils\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be" + } + ], + "description": "PHAR file format utilities, for when PHP phars you up", "keywords": [ - "json", - "linter", - "parser", - "validator" + "phar" ], "support": { - "issues": "https://github.com/Seldaek/jsonlint/issues", - "source": "https://github.com/Seldaek/jsonlint/tree/1.11.0" + "issues": "https://github.com/Seldaek/phar-utils/issues", + "source": "https://github.com/Seldaek/phar-utils/tree/1.2.1" }, - "funding": [ - { - "url": "https://github.com/Seldaek", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/seld/jsonlint", - "type": "tidelift" - } - ], - "time": "2024-07-11T14:55:45+00:00" + "time": "2022-08-31T10:31:18+00:00" }, { "name": "symfony/console", - "version": "v5.4.47", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed" + "reference": "23c8aae6d764e2bae02d2a99f7532a7f6ed619cf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed", - "reference": "c4ba980ca61a9eb18ee6bcc73f28e475852bb1ed", + "url": "https://api.github.com/repos/symfony/console/zipball/23c8aae6d764e2bae02d2a99f7532a7f6ed619cf", + "reference": "23c8aae6d764e2bae02d2a99f7532a7f6ed619cf", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/deprecation-contracts": "^2.1|^3", + "php": ">=8.2", "symfony/polyfill-mbstring": "~1.0", - "symfony/polyfill-php73": "^1.9", - "symfony/polyfill-php80": "^1.16", - "symfony/service-contracts": "^1.1|^2|^3", - "symfony/string": "^5.1|^6.0" + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^6.4|^7.0" }, "conflict": { - "psr/log": ">=3", - "symfony/dependency-injection": "<4.4", - "symfony/dotenv": "<5.1", - "symfony/event-dispatcher": "<4.4", - "symfony/lock": "<4.4", - "symfony/process": "<4.4" + "symfony/dependency-injection": "<6.4", + "symfony/dotenv": "<6.4", + "symfony/event-dispatcher": "<6.4", + "symfony/lock": "<6.4", + "symfony/process": "<6.4" }, "provide": { - "psr/log-implementation": "1.0|2.0" + "psr/log-implementation": "1.0|2.0|3.0" }, "require-dev": { - "psr/log": "^1|^2", - "symfony/config": "^4.4|^5.0|^6.0", - "symfony/dependency-injection": "^4.4|^5.0|^6.0", - "symfony/event-dispatcher": "^4.4|^5.0|^6.0", - "symfony/lock": "^4.4|^5.0|^6.0", - "symfony/process": "^4.4|^5.0|^6.0", - "symfony/var-dumper": "^4.4|^5.0|^6.0" - }, - "suggest": { - "psr/log": "For using the console logger", - "symfony/event-dispatcher": "", - "symfony/lock": "", - "symfony/process": "" + "psr/log": "^1|^2|^3", + "symfony/config": "^6.4|^7.0", + "symfony/dependency-injection": "^6.4|^7.0", + "symfony/event-dispatcher": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/lock": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/stopwatch": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -2222,7 +2791,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v5.4.47" + "source": "https://github.com/symfony/console/tree/v7.2.0" }, "funding": [ { @@ -2238,29 +2807,29 @@ "type": "tidelift" } ], - "time": "2024-11-06T11:30:55+00:00" + "time": "2024-11-06T14:24:19+00:00" }, { "name": "symfony/deprecation-contracts", - "version": "v2.5.3", + "version": "v3.5.1", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "80d075412b557d41002320b96a096ca65aa2c98d" + "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/80d075412b557d41002320b96a096ca65aa2c98d", - "reference": "80d075412b557d41002320b96a096ca65aa2c98d", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", + "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", "shasum": "" }, "require": { - "php": ">=7.1" + "php": ">=8.1" }, "type": "library", "extra": { "branch-alias": { - "dev-main": "2.5-dev" + "dev-main": "3.5-dev" }, "thanks": { "name": "symfony/contracts", @@ -2289,7 +2858,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v2.5.3" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1" }, "funding": [ { @@ -2305,33 +2874,30 @@ "type": "tidelift" } ], - "time": "2023-01-24T14:02:46+00:00" + "time": "2024-09-25T14:20:29+00:00" }, { "name": "symfony/event-dispatcher-contracts", - "version": "v2.5.3", + "version": "v3.5.1", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher-contracts.git", - "reference": "540f4c73e87fd0c71ca44a6aa305d024ac68cb73" + "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/540f4c73e87fd0c71ca44a6aa305d024ac68cb73", - "reference": "540f4c73e87fd0c71ca44a6aa305d024ac68cb73", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/7642f5e970b672283b7823222ae8ef8bbc160b9f", + "reference": "7642f5e970b672283b7823222ae8ef8bbc160b9f", "shasum": "" }, "require": { - "php": ">=7.2.5", + "php": ">=8.1", "psr/event-dispatcher": "^1" }, - "suggest": { - "symfony/event-dispatcher-implementation": "" - }, "type": "library", "extra": { "branch-alias": { - "dev-main": "2.5-dev" + "dev-main": "3.5-dev" }, "thanks": { "name": "symfony/contracts", @@ -2368,7 +2934,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v2.5.3" + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.5.1" }, "funding": [ { @@ -2384,30 +2950,29 @@ "type": "tidelift" } ], - "time": "2024-01-23T13:51:25+00:00" + "time": "2024-09-25T14:20:29+00:00" }, { "name": "symfony/filesystem", - "version": "v5.4.45", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/filesystem.git", - "reference": "57c8294ed37d4a055b77057827c67f9558c95c54" + "reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/filesystem/zipball/57c8294ed37d4a055b77057827c67f9558c95c54", - "reference": "57c8294ed37d4a055b77057827c67f9558c95c54", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/b8dce482de9d7c9fe2891155035a7248ab5c7fdb", + "reference": "b8dce482de9d7c9fe2891155035a7248ab5c7fdb", "shasum": "" }, "require": { - "php": ">=7.2.5", + "php": ">=8.2", "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.8", - "symfony/polyfill-php80": "^1.16" + "symfony/polyfill-mbstring": "~1.8" }, "require-dev": { - "symfony/process": "^5.4|^6.4" + "symfony/process": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -2435,7 +3000,7 @@ "description": "Provides basic utilities for the filesystem", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/filesystem/tree/v5.4.45" + "source": "https://github.com/symfony/filesystem/tree/v7.2.0" }, "funding": [ { @@ -2451,26 +3016,27 @@ "type": "tidelift" } ], - "time": "2024-10-22T13:05:35+00:00" + "time": "2024-10-25T15:15:23+00:00" }, { "name": "symfony/finder", - "version": "v5.4.45", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "63741784cd7b9967975eec610b256eed3ede022b" + "reference": "6de263e5868b9a137602dd1e33e4d48bfae99c49" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/63741784cd7b9967975eec610b256eed3ede022b", - "reference": "63741784cd7b9967975eec610b256eed3ede022b", + "url": "https://api.github.com/repos/symfony/finder/zipball/6de263e5868b9a137602dd1e33e4d48bfae99c49", + "reference": "6de263e5868b9a137602dd1e33e4d48bfae99c49", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/deprecation-contracts": "^2.1|^3", - "symfony/polyfill-php80": "^1.16" + "php": ">=8.2" + }, + "require-dev": { + "symfony/filesystem": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -2498,7 +3064,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v5.4.45" + "source": "https://github.com/symfony/finder/tree/v7.2.0" }, "funding": [ { @@ -2514,7 +3080,7 @@ "type": "tidelift" } ], - "time": "2024-09-28T13:32:08+00:00" + "time": "2024-10-23T06:56:12+00:00" }, { "name": "symfony/polyfill-ctype", @@ -2596,24 +3162,27 @@ "time": "2024-09-09T11:45:10+00:00" }, { - "name": "symfony/polyfill-intl-grapheme", + "name": "symfony/polyfill-iconv", "version": "v1.31.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" + "url": "https://github.com/symfony/polyfill-iconv.git", + "reference": "48becf00c920479ca2e910c22a5a39e5d47ca956" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", - "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "url": "https://api.github.com/repos/symfony/polyfill-iconv/zipball/48becf00c920479ca2e910c22a5a39e5d47ca956", + "reference": "48becf00c920479ca2e910c22a5a39e5d47ca956", "shasum": "" }, "require": { "php": ">=7.2" }, + "provide": { + "ext-iconv": "*" + }, "suggest": { - "ext-intl": "For best performance" + "ext-iconv": "For best performance" }, "type": "library", "extra": { @@ -2627,7 +3196,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + "Symfony\\Polyfill\\Iconv\\": "" } }, "notification-url": "https://packagist.org/downloads/", @@ -2644,18 +3213,17 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for intl's grapheme_* functions", + "description": "Symfony polyfill for the Iconv extension", "homepage": "https://symfony.com", "keywords": [ "compatibility", - "grapheme", - "intl", + "iconv", "polyfill", "portable", "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-iconv/tree/v1.31.0" }, "funding": [ { @@ -2674,17 +3242,17 @@ "time": "2024-09-09T11:45:10+00:00" }, { - "name": "symfony/polyfill-intl-normalizer", + "name": "symfony/polyfill-intl-grapheme", "version": "v1.31.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", + "reference": "b9123926e3b7bc2f98c02ad54f6a4b02b91a8abe", "shasum": "" }, "require": { @@ -2705,11 +3273,8 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Intl\\Normalizer\\": "" - }, - "classmap": [ - "Resources/stubs" - ] + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2725,18 +3290,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill for intl's Normalizer class and related functions", + "description": "Symfony polyfill for intl's grapheme_* functions", "homepage": "https://symfony.com", "keywords": [ "compatibility", + "grapheme", "intl", - "normalizer", "polyfill", "portable", "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.31.0" }, "funding": [ { @@ -2755,101 +3320,24 @@ "time": "2024-09-09T11:45:10+00:00" }, { - "name": "symfony/polyfill-mbstring", + "name": "symfony/polyfill-intl-normalizer", "version": "v1.31.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "3833d7255cc303546435cb650316bff708a1c75c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", - "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", + "reference": "3833d7255cc303546435cb650316bff708a1c75c", "shasum": "" }, "require": { "php": ">=7.2" }, - "provide": { - "ext-mbstring": "*" - }, "suggest": { - "ext-mbstring": "For best performance" - }, - "type": "library", - "extra": { - "thanks": { - "name": "symfony/polyfill", - "url": "https://github.com/symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Mbstring\\": "" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill for the Mbstring extension", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "mbstring", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2024-09-09T11:45:10+00:00" - }, - { - "name": "symfony/polyfill-php80", - "version": "v1.31.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", - "reference": "60328e362d4c2c802a54fcbf04f9d3fb892b4cf8", - "shasum": "" - }, - "require": { - "php": ">=7.2" + "ext-intl": "For best performance" }, "type": "library", "extra": { @@ -2863,7 +3351,7 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php80\\": "" + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" }, "classmap": [ "Resources/stubs" @@ -2874,10 +3362,6 @@ "MIT" ], "authors": [ - { - "name": "Ion Bazan", - "email": "ion.bazan@gmail.com" - }, { "name": "Nicolas Grekas", "email": "p@tchwork.com" @@ -2887,16 +3371,18 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "description": "Symfony polyfill for intl's Normalizer class and related functions", "homepage": "https://symfony.com", "keywords": [ "compatibility", + "intl", + "normalizer", "polyfill", "portable", "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.31.0" }, "funding": [ { @@ -2915,22 +3401,28 @@ "time": "2024-09-09T11:45:10+00:00" }, { - "name": "symfony/polyfill-php81", + "name": "symfony/polyfill-mbstring", "version": "v1.31.0", "source": { "type": "git", - "url": "https://github.com/symfony/polyfill-php81.git", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", - "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", "shasum": "" }, "require": { "php": ">=7.2" }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, "type": "library", "extra": { "thanks": { @@ -2943,11 +3435,8 @@ "bootstrap.php" ], "psr-4": { - "Symfony\\Polyfill\\Php81\\": "" - }, - "classmap": [ - "Resources/stubs" - ] + "Symfony\\Polyfill\\Mbstring\\": "" + } }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -2963,16 +3452,17 @@ "homepage": "https://symfony.com/contributors" } ], - "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "description": "Symfony polyfill for the Mbstring extension", "homepage": "https://symfony.com", "keywords": [ "compatibility", + "mbstring", "polyfill", "portable", "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.31.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" }, "funding": [ { @@ -2992,21 +3482,20 @@ }, { "name": "symfony/process", - "version": "v5.4.47", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "5d1662fb32ebc94f17ddb8d635454a776066733d" + "reference": "d34b22ba9390ec19d2dd966c40aa9e8462f27a7e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/5d1662fb32ebc94f17ddb8d635454a776066733d", - "reference": "5d1662fb32ebc94f17ddb8d635454a776066733d", + "url": "https://api.github.com/repos/symfony/process/zipball/d34b22ba9390ec19d2dd966c40aa9e8462f27a7e", + "reference": "d34b22ba9390ec19d2dd966c40aa9e8462f27a7e", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/polyfill-php80": "^1.16" + "php": ">=8.2" }, "type": "library", "autoload": { @@ -3034,7 +3523,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v5.4.47" + "source": "https://github.com/symfony/process/tree/v7.2.0" }, "funding": [ { @@ -3050,37 +3539,34 @@ "type": "tidelift" } ], - "time": "2024-11-06T11:36:42+00:00" + "time": "2024-11-06T14:24:19+00:00" }, { "name": "symfony/service-contracts", - "version": "v2.5.3", + "version": "v3.5.1", "source": { "type": "git", "url": "https://github.com/symfony/service-contracts.git", - "reference": "a2329596ddc8fd568900e3fc76cba42489ecc7f3" + "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/service-contracts/zipball/a2329596ddc8fd568900e3fc76cba42489ecc7f3", - "reference": "a2329596ddc8fd568900e3fc76cba42489ecc7f3", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/e53260aabf78fb3d63f8d79d69ece59f80d5eda0", + "reference": "e53260aabf78fb3d63f8d79d69ece59f80d5eda0", "shasum": "" }, "require": { - "php": ">=7.2.5", - "psr/container": "^1.1", - "symfony/deprecation-contracts": "^2.1|^3" + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" }, "conflict": { "ext-psr": "<1.1|>=2" }, - "suggest": { - "symfony/service-implementation": "" - }, "type": "library", "extra": { "branch-alias": { - "dev-main": "2.5-dev" + "dev-main": "3.5-dev" }, "thanks": { "name": "symfony/contracts", @@ -3090,7 +3576,10 @@ "autoload": { "psr-4": { "Symfony\\Contracts\\Service\\": "" - } + }, + "exclude-from-classmap": [ + "/Test/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -3117,7 +3606,7 @@ "standards" ], "support": { - "source": "https://github.com/symfony/service-contracts/tree/v2.5.3" + "source": "https://github.com/symfony/service-contracts/tree/v3.5.1" }, "funding": [ { @@ -3133,38 +3622,39 @@ "type": "tidelift" } ], - "time": "2023-04-21T15:04:16+00:00" + "time": "2024-09-25T14:20:29+00:00" }, { "name": "symfony/string", - "version": "v5.4.47", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "136ca7d72f72b599f2631aca474a4f8e26719799" + "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/136ca7d72f72b599f2631aca474a4f8e26719799", - "reference": "136ca7d72f72b599f2631aca474a4f8e26719799", + "url": "https://api.github.com/repos/symfony/string/zipball/446e0d146f991dde3e73f45f2c97a9faad773c82", + "reference": "446e0d146f991dde3e73f45f2c97a9faad773c82", "shasum": "" }, "require": { - "php": ">=7.2.5", + "php": ">=8.2", "symfony/polyfill-ctype": "~1.8", "symfony/polyfill-intl-grapheme": "~1.0", "symfony/polyfill-intl-normalizer": "~1.0", - "symfony/polyfill-mbstring": "~1.0", - "symfony/polyfill-php80": "~1.15" + "symfony/polyfill-mbstring": "~1.0" }, "conflict": { - "symfony/translation-contracts": ">=3.0" + "symfony/translation-contracts": "<2.5" }, "require-dev": { - "symfony/error-handler": "^4.4|^5.0|^6.0", - "symfony/http-client": "^4.4|^5.0|^6.0", - "symfony/translation-contracts": "^1.1|^2", - "symfony/var-exporter": "^4.4|^5.0|^6.0" + "symfony/emoji": "^7.1", + "symfony/error-handler": "^6.4|^7.0", + "symfony/http-client": "^6.4|^7.0", + "symfony/intl": "^6.4|^7.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^6.4|^7.0" }, "type": "library", "autoload": { @@ -3203,7 +3693,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v5.4.47" + "source": "https://github.com/symfony/string/tree/v7.2.0" }, "funding": [ { @@ -3219,42 +3709,36 @@ "type": "tidelift" } ], - "time": "2024-11-10T20:33:58+00:00" + "time": "2024-11-13T13:31:26+00:00" }, { "name": "symfony/var-dumper", - "version": "v5.4.47", + "version": "v7.2.0", "source": { "type": "git", "url": "https://github.com/symfony/var-dumper.git", - "reference": "e13e8dfa8eaab2b0536ef365beddc2af723a9ac0" + "reference": "c6a22929407dec8765d6e2b6ff85b800b245879c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/var-dumper/zipball/e13e8dfa8eaab2b0536ef365beddc2af723a9ac0", - "reference": "e13e8dfa8eaab2b0536ef365beddc2af723a9ac0", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/c6a22929407dec8765d6e2b6ff85b800b245879c", + "reference": "c6a22929407dec8765d6e2b6ff85b800b245879c", "shasum": "" }, "require": { - "php": ">=7.2.5", - "symfony/polyfill-mbstring": "~1.0", - "symfony/polyfill-php80": "^1.16" + "php": ">=8.2", + "symfony/polyfill-mbstring": "~1.0" }, "conflict": { - "symfony/console": "<4.4" + "symfony/console": "<6.4" }, "require-dev": { "ext-iconv": "*", - "symfony/console": "^4.4|^5.0|^6.0", - "symfony/http-kernel": "^4.4|^5.0|^6.0", - "symfony/process": "^4.4|^5.0|^6.0", - "symfony/uid": "^5.1|^6.0", - "twig/twig": "^2.13|^3.0.4" - }, - "suggest": { - "ext-iconv": "To convert non-UTF-8 strings to UTF-8 (or symfony/polyfill-iconv in case ext-iconv cannot be used).", - "ext-intl": "To show region name in time zone dump", - "symfony/console": "To use the ServerDumpCommand and/or the bin/var-dump-server script" + "symfony/console": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/process": "^6.4|^7.0", + "symfony/uid": "^6.4|^7.0", + "twig/twig": "^3.12" }, "bin": [ "Resources/bin/var-dump-server" @@ -3292,7 +3776,7 @@ "dump" ], "support": { - "source": "https://github.com/symfony/var-dumper/tree/v5.4.47" + "source": "https://github.com/symfony/var-dumper/tree/v7.2.0" }, "funding": [ { @@ -3308,43 +3792,50 @@ "type": "tidelift" } ], - "time": "2024-11-08T15:21:10+00:00" + "time": "2024-11-08T15:48:14+00:00" }, { "name": "thecodingmachine/safe", - "version": "v1.3.3", + "version": "v2.5.0", "source": { "type": "git", "url": "https://github.com/thecodingmachine/safe.git", - "reference": "a8ab0876305a4cdaef31b2350fcb9811b5608dbc" + "reference": "3115ecd6b4391662b4931daac4eba6b07a2ac1f0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/a8ab0876305a4cdaef31b2350fcb9811b5608dbc", - "reference": "a8ab0876305a4cdaef31b2350fcb9811b5608dbc", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/3115ecd6b4391662b4931daac4eba6b07a2ac1f0", + "reference": "3115ecd6b4391662b4931daac4eba6b07a2ac1f0", "shasum": "" }, "require": { - "php": ">=7.2" + "php": "^8.0" }, "require-dev": { - "phpstan/phpstan": "^0.12", + "phpstan/phpstan": "^1.5", + "phpunit/phpunit": "^9.5", "squizlabs/php_codesniffer": "^3.2", - "thecodingmachine/phpstan-strict-rules": "^0.12" + "thecodingmachine/phpstan-strict-rules": "^1.0" }, "type": "library", "extra": { "branch-alias": { - "dev-master": "0.1-dev" + "dev-master": "2.2.x-dev" } }, "autoload": { "files": [ "deprecated/apc.php", + "deprecated/array.php", + "deprecated/datetime.php", "deprecated/libevent.php", + "deprecated/misc.php", + "deprecated/password.php", "deprecated/mssql.php", "deprecated/stats.php", + "deprecated/strings.php", "lib/special_cases.php", + "deprecated/mysqli.php", "generated/apache.php", "generated/apcu.php", "generated/array.php", @@ -3365,6 +3856,7 @@ "generated/fpm.php", "generated/ftp.php", "generated/funchand.php", + "generated/gettext.php", "generated/gmp.php", "generated/gnupg.php", "generated/hash.php", @@ -3374,7 +3866,6 @@ "generated/image.php", "generated/imap.php", "generated/info.php", - "generated/ingres-ii.php", "generated/inotify.php", "generated/json.php", "generated/ldap.php", @@ -3383,20 +3874,14 @@ "generated/mailparse.php", "generated/mbstring.php", "generated/misc.php", - "generated/msql.php", "generated/mysql.php", - "generated/mysqli.php", - "generated/mysqlndMs.php", - "generated/mysqlndQc.php", "generated/network.php", "generated/oci8.php", "generated/opcache.php", "generated/openssl.php", "generated/outcontrol.php", - "generated/password.php", "generated/pcntl.php", "generated/pcre.php", - "generated/pdf.php", "generated/pgsql.php", "generated/posix.php", "generated/ps.php", @@ -3407,7 +3892,6 @@ "generated/sem.php", "generated/session.php", "generated/shmop.php", - "generated/simplexml.php", "generated/sockets.php", "generated/sodium.php", "generated/solr.php", @@ -3430,13 +3914,13 @@ "generated/zip.php", "generated/zlib.php" ], - "psr-4": { - "Safe\\": [ - "lib/", - "deprecated/", - "generated/" - ] - } + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "deprecated/Exceptions/", + "generated/Exceptions/" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -3445,59 +3929,9 @@ "description": "PHP core functions that throw exceptions instead of returning FALSE on error", "support": { "issues": "https://github.com/thecodingmachine/safe/issues", - "source": "https://github.com/thecodingmachine/safe/tree/v1.3.3" - }, - "time": "2020-10-28T17:51:34+00:00" - }, - { - "name": "ulrichsg/getopt-php", - "version": "v3.4.0", - "source": { - "type": "git", - "url": "https://github.com/getopt-php/getopt-php.git", - "reference": "9121d7c2c51a6a59ee407c49a13b4d8cfae71075" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/getopt-php/getopt-php/zipball/9121d7c2c51a6a59ee407c49a13b4d8cfae71075", - "reference": "9121d7c2c51a6a59ee407c49a13b4d8cfae71075", - "shasum": "" - }, - "require": { - "ext-mbstring": "*", - "php": ">=5.4.0" - }, - "require-dev": { - "phpunit/phpunit": "^4.8", - "squizlabs/php_codesniffer": "^2.7" - }, - "type": "library", - "autoload": { - "psr-4": { - "GetOpt\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ulrich Schmidt-Goertz", - "email": "ulrich@schmidt-goertz.de" - }, - { - "name": "Thomas Flori", - "email": "thflori@gmail.com" - } - ], - "description": "Command line arguments parser for PHP 5.4 - 7.3", - "homepage": "http://getopt-php.github.io/getopt-php", - "support": { - "issues": "https://github.com/getopt-php/getopt-php/issues", - "source": "https://github.com/getopt-php/getopt-php/tree/v3.4.0" + "source": "https://github.com/thecodingmachine/safe/tree/v2.5.0" }, - "time": "2020-07-14T06:09:04+00:00" + "time": "2023-04-05T11:54:14+00:00" }, { "name": "webmozart/assert", @@ -3556,68 +3990,17 @@ "source": "https://github.com/webmozarts/assert/tree/1.11.0" }, "time": "2022-06-03T18:03:27+00:00" - }, - { - "name": "webmozart/path-util", - "version": "2.3.0", - "source": { - "type": "git", - "url": "https://github.com/webmozart/path-util.git", - "reference": "d939f7edc24c9a1bb9c0dee5cb05d8e859490725" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozart/path-util/zipball/d939f7edc24c9a1bb9c0dee5cb05d8e859490725", - "reference": "d939f7edc24c9a1bb9c0dee5cb05d8e859490725", - "shasum": "" - }, - "require": { - "php": ">=5.3.3", - "webmozart/assert": "~1.0" - }, - "require-dev": { - "phpunit/phpunit": "^4.6", - "sebastian/version": "^1.0.1" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.3-dev" - } - }, - "autoload": { - "psr-4": { - "Webmozart\\PathUtil\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - } - ], - "description": "A robust cross-platform utility for normalizing, comparing and modifying file paths.", - "support": { - "issues": "https://github.com/webmozart/path-util/issues", - "source": "https://github.com/webmozart/path-util/tree/2.3.0" - }, - "abandoned": "symfony/filesystem", - "time": "2015-12-17T08:42:14+00:00" } ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, - "platform": [], - "platform-dev": [], + "platform": {}, + "platform-dev": {}, "platform-overrides": { - "php": "7.4.13" + "php": "8.2.0" }, "plugin-api-version": "2.6.0" }