From 77ecd5ce01ee047227d97f4476c4185647d38491 Mon Sep 17 00:00:00 2001 From: Alex Skrypnyk Date: Sun, 1 Sep 2024 18:36:30 +1000 Subject: [PATCH] Moved back installer into the project. --- .github/workflows/vortex-release-docs.yml | 15 + .../workflows/vortex-release-installer.yml | 55 + .github/workflows/vortex-test-installer.yml | 61 + .vortex/.ahoy.yml | 5 +- .../.github/workflows/release-php.yml | 78 + .../installer/.github/workflows/test-php.yml | 56 + .vortex/installer/.gitignore | 7 + .vortex/installer/README.md | 42 + .vortex/installer/box.json | 32 + .vortex/installer/composer.json | 74 + .vortex/installer/install | 12 + .vortex/installer/phpcs.xml | 34 + .vortex/installer/phpmd.xml | 15 + .vortex/installer/phpstan.neon | 23 + .vortex/installer/phpunit.xml | 37 + .vortex/installer/rector.php | 61 + .../installer/src/Command/InstallCommand.php | 2349 +++++++++++++++++ .vortex/installer/src/app.php | 20 + .../Fixtures/copyfiles/dir/file_in_dir.txt | 0 .../copyfiles/dir/subdir/file_in_subdir.txt | 0 .../dir/subdir/file_link_from_subdir.txt | 1 + .../Fixtures/copyfiles/dir/subdir_link | 1 + .../tests/phpunit/Fixtures/copyfiles/dir_link | 1 + .../tests/phpunit/Fixtures/copyfiles/file.txt | 1 + .../phpunit/Fixtures/copyfiles/file_link.txt | 1 + .../Fixtures/copyfiles/subdir_link_root | 1 + .../phpunit/Fixtures/tokens/dir1/foobar_b.txt | 5 + .../phpunit/Fixtures/tokens/dir1/foofoo_b.txt | 5 + .../tests/phpunit/Fixtures/tokens/empty.txt | 0 .../phpunit/Fixtures/tokens/foo/foobar_b.txt | 5 + .../phpunit/Fixtures/tokens/foo/foofoo_b.txt | 5 + .../phpunit/Fixtures/tokens/foobar_b.txt | 5 + .../phpunit/Fixtures/tokens/foobar_e.txt | 5 + .../phpunit/Fixtures/tokens/foobar_m.txt | 6 + .../phpunit/Fixtures/tokens/foofoo_b.txt | 5 + .../phpunit/Fixtures/tokens/foofoo_e.txt | 5 + .../phpunit/Fixtures/tokens/foofoo_m.txt | 6 + .../tests/phpunit/Fixtures/tokens/lines_1.txt | 1 + .../phpunit/Fixtures/tokens/lines_123.txt | 3 + .../phpunit/Fixtures/tokens/lines_1234.txt | 4 + .../phpunit/Fixtures/tokens/lines_14.txt | 2 + .../phpunit/Fixtures/tokens/lines_234.txt | 3 + .../tests/phpunit/Fixtures/tokens/lines_4.txt | 1 + .../tests/phpunit/Traits/ReflectionTrait.php | 100 + .../Unit/Command/InstallCommandTest.php | 43 + .../tests/phpunit/Unit/CopyRecursiveTest.php | 64 + .../tests/phpunit/Unit/DotEnvTest.php | 226 ++ .../tests/phpunit/Unit/HelpersTest.php | 159 ++ .../tests/phpunit/Unit/TokenTest.php | 239 ++ .../tests/phpunit/Unit/UnitTestBase.php | 87 + .vortex/tests/bats/_helper.bash | 32 +- 51 files changed, 3968 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/vortex-release-installer.yml create mode 100644 .github/workflows/vortex-test-installer.yml create mode 100644 .vortex/installer/.github/workflows/release-php.yml create mode 100644 .vortex/installer/.github/workflows/test-php.yml create mode 100644 .vortex/installer/.gitignore create mode 100644 .vortex/installer/README.md create mode 100644 .vortex/installer/box.json create mode 100644 .vortex/installer/composer.json create mode 100755 .vortex/installer/install create mode 100644 .vortex/installer/phpcs.xml create mode 100644 .vortex/installer/phpmd.xml create mode 100644 .vortex/installer/phpstan.neon create mode 100644 .vortex/installer/phpunit.xml create mode 100644 .vortex/installer/rector.php create mode 100644 .vortex/installer/src/Command/InstallCommand.php create mode 100644 .vortex/installer/src/app.php create mode 100644 .vortex/installer/tests/phpunit/Fixtures/copyfiles/dir/file_in_dir.txt create mode 100644 .vortex/installer/tests/phpunit/Fixtures/copyfiles/dir/subdir/file_in_subdir.txt create mode 120000 .vortex/installer/tests/phpunit/Fixtures/copyfiles/dir/subdir/file_link_from_subdir.txt create mode 120000 .vortex/installer/tests/phpunit/Fixtures/copyfiles/dir/subdir_link create mode 120000 .vortex/installer/tests/phpunit/Fixtures/copyfiles/dir_link create mode 100755 .vortex/installer/tests/phpunit/Fixtures/copyfiles/file.txt create mode 120000 .vortex/installer/tests/phpunit/Fixtures/copyfiles/file_link.txt create mode 120000 .vortex/installer/tests/phpunit/Fixtures/copyfiles/subdir_link_root create mode 100644 .vortex/installer/tests/phpunit/Fixtures/tokens/dir1/foobar_b.txt create mode 100644 .vortex/installer/tests/phpunit/Fixtures/tokens/dir1/foofoo_b.txt create mode 100644 .vortex/installer/tests/phpunit/Fixtures/tokens/empty.txt create mode 100644 .vortex/installer/tests/phpunit/Fixtures/tokens/foo/foobar_b.txt create mode 100644 .vortex/installer/tests/phpunit/Fixtures/tokens/foo/foofoo_b.txt create mode 100644 .vortex/installer/tests/phpunit/Fixtures/tokens/foobar_b.txt create mode 100644 .vortex/installer/tests/phpunit/Fixtures/tokens/foobar_e.txt create mode 100644 .vortex/installer/tests/phpunit/Fixtures/tokens/foobar_m.txt create mode 100644 .vortex/installer/tests/phpunit/Fixtures/tokens/foofoo_b.txt create mode 100644 .vortex/installer/tests/phpunit/Fixtures/tokens/foofoo_e.txt create mode 100644 .vortex/installer/tests/phpunit/Fixtures/tokens/foofoo_m.txt create mode 100644 .vortex/installer/tests/phpunit/Fixtures/tokens/lines_1.txt create mode 100644 .vortex/installer/tests/phpunit/Fixtures/tokens/lines_123.txt create mode 100644 .vortex/installer/tests/phpunit/Fixtures/tokens/lines_1234.txt create mode 100644 .vortex/installer/tests/phpunit/Fixtures/tokens/lines_14.txt create mode 100644 .vortex/installer/tests/phpunit/Fixtures/tokens/lines_234.txt create mode 100644 .vortex/installer/tests/phpunit/Fixtures/tokens/lines_4.txt create mode 100644 .vortex/installer/tests/phpunit/Traits/ReflectionTrait.php create mode 100644 .vortex/installer/tests/phpunit/Unit/Command/InstallCommandTest.php create mode 100644 .vortex/installer/tests/phpunit/Unit/CopyRecursiveTest.php create mode 100644 .vortex/installer/tests/phpunit/Unit/DotEnvTest.php create mode 100644 .vortex/installer/tests/phpunit/Unit/HelpersTest.php create mode 100644 .vortex/installer/tests/phpunit/Unit/TokenTest.php create mode 100644 .vortex/installer/tests/phpunit/Unit/UnitTestBase.php diff --git a/.github/workflows/vortex-release-docs.yml b/.github/workflows/vortex-release-docs.yml index 8e47e7637..fc807d760 100644 --- a/.github/workflows/vortex-release-docs.yml +++ b/.github/workflows/vortex-release-docs.yml @@ -7,6 +7,11 @@ on: - '*' branches: - '**release-docs**' + workflow_run: + workflows: + - 'Vortex - Release installer' + types: + - completed permissions: contents: read @@ -40,6 +45,16 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 + - name: Download installer + uses: actions/download-artifact@v2 + with: + name: vortex-installer + + - name: Copy installer to docs + run: | + copy vortex-installer/installer .vortex/docs/static/installer + php .vortex/docs/static/installer --version + - name: Check docs up-to-date run: | composer --working-dir=.utils install diff --git a/.github/workflows/vortex-release-installer.yml b/.github/workflows/vortex-release-installer.yml new file mode 100644 index 000000000..468daab36 --- /dev/null +++ b/.github/workflows/vortex-release-installer.yml @@ -0,0 +1,55 @@ +# This action is used for Vortex maintenance. It will not be used in the scaffolded project. +name: Vortex - Release installer + +on: + push: + tags: + - '*' + branches: + - '**release-installer**' + +jobs: + vortex-release-installer: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: /tmp/composer-cache + key: ${{ runner.os }}-${{ hashFiles('**/composer.lock') }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.1 + + - name: Install dependencies + run: composer install + working-directory: .vortex/installer + + - name: Add version + run: | + TAG=${{ github.ref_type == 'tag' && github.ref_name || '' }} + SHA=${{ github.ref_type == 'branch' && github.sha || '' }} + sed -i "s/\"git-tag-ci\": \"dev\"/\"git-tag-ci\": \"${TAG:-${SHA}}\"/g" box.json + working-directory: .vortex/installer + + - name: Build PHAR + run: composer build + working-directory: .vortex/installer + + - name: Test PHAR + run: ./.build/installer --quiet || exit 1 + working-directory: .vortex/installer + + - name: Upload artifact + uses: actions/upload-artifact@v2 + with: + name: vortex-installer + path: .vortex/installer/.build/installer diff --git a/.github/workflows/vortex-test-installer.yml b/.github/workflows/vortex-test-installer.yml new file mode 100644 index 000000000..9d3f3a31d --- /dev/null +++ b/.github/workflows/vortex-test-installer.yml @@ -0,0 +1,61 @@ +# This action is used for Vortex maintenance. It will not be used in the scaffolded project. +name: Vortex - Test installer + +on: + push: + branches: + - main + pull_request: + branches: + - main + - 'feature/**' + +jobs: + vortex-test-installer: + runs-on: ubuntu-latest + + strategy: + matrix: + php-versions: ['8.1', '8.2', '8.3'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + + - name: Install dependencies + run: composer install + working-directory: .vortex/installer + + - name: Check coding standards + run: composer lint + working-directory: .vortex/installer + + - name: Run tests + run: XDEBUG_MODE=coverage composer test + working-directory: .vortex/installer + + - name: Upload coverage report as an artifact + uses: actions/upload-artifact@v4 + with: + name: ${{github.job}}-code-coverage-report-${{ matrix.php-versions }} + path: .vortex/installer/.coverage-html + + - name: Upload coverage report to Codecov + uses: codecov/codecov-action@v4 + with: + files: .vortex/installer/cobertura.xml + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} + + # Smoke test for PHAR. + - name: Build PHAR + run: composer build + working-directory: .vortex/installer + + - name: Test PHAR + run: .vortex/installer/.build/installer --quiet || exit 1 diff --git a/.vortex/.ahoy.yml b/.vortex/.ahoy.yml index 70aca5b29..4c5fd4387 100644 --- a/.vortex/.ahoy.yml +++ b/.vortex/.ahoy.yml @@ -6,8 +6,9 @@ commands: install: name: Install test dependencies. cmd: | - [ ! -d ./docs/node_modules ] && npm --prefix tests ci + [ ! -d ./tests/node_modules ] && npm --prefix tests ci [ ! -d ./docs/node_modules ] && npm --prefix docs ci + [ ! -d ./installer/node_modules ] && composer --working-dir installer install docs: name: Start documentation server. @@ -48,7 +49,7 @@ commands: test-bats: cmd: | - [ ! -d tests/node_modules ] && ahoy install + [ ! -d tests/node_modules ] && npm --prefix tests ci tests/node_modules/.bin/bats "$@" test-common: diff --git a/.vortex/installer/.github/workflows/release-php.yml b/.vortex/installer/.github/workflows/release-php.yml new file mode 100644 index 000000000..49f2ac713 --- /dev/null +++ b/.vortex/installer/.github/workflows/release-php.yml @@ -0,0 +1,78 @@ +name: Release PHP + +on: + push: + tags: + - '*' + +jobs: + release-php: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: /tmp/composer-cache + key: ${{ runner.os }}-${{ hashFiles('**/composer.lock') }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.1 + + - name: Install dependencies + run: composer install + + - name: Add version + run: | + TAG=${{ github.ref_type == 'tag' && github.ref_name || '' }} + SHA=${{ github.ref_type == 'branch' && github.sha || '' }} + sed -i "s/\"git-tag-ci\": \"dev\"/\"git-tag-ci\": \"${TAG:-${SHA}}\"/g" box.json + + - name: Build PHAR + run: composer build + + - name: Test PHAR + run: ./.build/installer --quiet || exit 1 + + - name: Prepare publish artifact + run: | + mkdir -p /tmp/installer/docs + cp .build/installer /tmp/installer/docs/index.html + echo "install.drevops.com" > /tmp/installer/docs/CNAME + + - name: Setup SSH private key + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.PUBLISH_SSH_PRIVATE_KEY }} + + - name: Publish + run: | + DST_BRANCH=bin + git config --global user.name "Deployment robot" + git config --global user.email "deploy+installer@drevops.com" + cd /tmp/installer + git init + git checkout -b "${DST_BRANCH}" + git add -A + git commit -m "Automatically pushed from drevops/vortex-installer 'main' branch." + git remote add origin git@github.com:drevops/vortex-installer.git + git push origin "${DST_BRANCH}" --force + + - name: Get tag name + if: github.ref_type == 'tag' + id: get-version + run: echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + + - name: Create Release + if: github.ref_type == 'tag' + uses: softprops/action-gh-release@v2 + with: + files: | + ./.build/installer diff --git a/.vortex/installer/.github/workflows/test-php.yml b/.vortex/installer/.github/workflows/test-php.yml new file mode 100644 index 000000000..b6987effb --- /dev/null +++ b/.vortex/installer/.github/workflows/test-php.yml @@ -0,0 +1,56 @@ +name: Test PHP + +on: + push: + branches: + - main + pull_request: + branches: + - main + - 'feature/**' + +jobs: + test-php: + runs-on: ubuntu-latest + + strategy: + matrix: + php-versions: ['8.1', '8.2', '8.3'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + + - name: Install dependencies + run: composer install + + - name: Check coding standards + run: composer lint + + - name: Run tests + run: XDEBUG_MODE=coverage composer test + + - name: Upload coverage report as an artifact + uses: actions/upload-artifact@v4 + with: + name: ${{github.job}}-code-coverage-report-${{ matrix.php-versions }} + path: .coverage-html + + - name: Upload coverage report to Codecov + uses: codecov/codecov-action@v4 + with: + files: cobertura.xml + fail_ci_if_error: true + token: ${{ secrets.CODECOV_TOKEN }} + + # Smoke test for PHAR. + - name: Build PHAR + run: composer build + + - name: Test PHAR + run: ./.build/installer --quiet || exit 1 diff --git a/.vortex/installer/.gitignore b/.vortex/installer/.gitignore new file mode 100644 index 000000000..c134353d7 --- /dev/null +++ b/.vortex/installer/.gitignore @@ -0,0 +1,7 @@ +/.build +/.coverage-html +/.phpunit.cache +/cobertura.xml +/composer.lock +/vendor +/vendor-bin diff --git a/.vortex/installer/README.md b/.vortex/installer/README.md new file mode 100644 index 000000000..0eec0bc1a --- /dev/null +++ b/.vortex/installer/README.md @@ -0,0 +1,42 @@ +

+ + DrevOps Installer logo +

+ +

Installer for Vortex project.

+ +
+ +[![GitHub Issues](https://img.shields.io/github/issues/drevops/vortex-installer.svg)](https://github.com/drevops/vortex-installer/issues) +[![GitHub Pull Requests](https://img.shields.io/github/issues-pr/drevops/vortex-installer.svg)](https://github.com/drevops/vortex-installer/pulls) +[![Test PHP](https://github.com/drevops/vortex-installer/actions/workflows/test-php.yml/badge.svg)](https://github.com/drevops/vortex-installer/actions/workflows/test-php.yml) +[![codecov](https://codecov.io/gh/drevops/vortex-installer/graph/badge.svg?token=K9SPETWCJR)](https://codecov.io/gh/drevops/vortex-installer) +![GitHub release (latest by date)](https://img.shields.io/github/v/release/drevops/vortex-installer) +![LICENSE](https://img.shields.io/github/license/drevops/vortex-installer) +![Renovate](https://img.shields.io/badge/renovate-enabled-green?logo=renovatebot) + +
+ +> [!IMPORTANT] +> We are working on the `v2` on the installer in +> the [`2.x`](https://github.com/drevops/vortex-installer/tree/2.x) branch. + +## Installation + +Download and run the latest version of the installer: + +```bash +curl -SsL https://install.drevops.com > install.php +php install.php +rm -r install.php +``` + +## Maintenance + + composer install + composer lint + composer test + +### Releasing + +The installer is packaged as a PHAR and deployed to https://install.drevops.com/ upon each GitHub release. diff --git a/.vortex/installer/box.json b/.vortex/installer/box.json new file mode 100644 index 000000000..f7c933c88 --- /dev/null +++ b/.vortex/installer/box.json @@ -0,0 +1,32 @@ +{ + "output": ".build/installer", + "banner": [ + "@file", + "Vortex CLI installer.", + "", + "", + "CLI installer for Vortex project.
", + "Run in your terminal:
", + "curl -SsL https://install.drevops.com > install.php && php install.php; rm -r install.php
", + "More details: https://vortex.drevops.com", + " + + + + + + + + + src + tests/phpunit + + + * + + + + + *.Test\.php + *.TestCase\.php + *.test + + + + + *.Test\.php + *.TestCase\.php + *.test + + diff --git a/.vortex/installer/phpmd.xml b/.vortex/installer/phpmd.xml new file mode 100644 index 000000000..7d3cedb37 --- /dev/null +++ b/.vortex/installer/phpmd.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + diff --git a/.vortex/installer/phpstan.neon b/.vortex/installer/phpstan.neon new file mode 100644 index 000000000..aeb474883 --- /dev/null +++ b/.vortex/installer/phpstan.neon @@ -0,0 +1,23 @@ +## +# Configuration file for PHPStan static code checking, see https://phpstan.org . +# + +parameters: + + level: 1 + + paths: + - src + - tests/phpunit + + excludePaths: + - vendor/* + + ignoreErrors: + - + # Since tests and data providers do not have to have parameter docblocks, + # it is not possible to specify the type of the parameter, so we ignore + # this error. + message: '#.*no value type specified in iterable type array.#' + path: tests/phpunit/* + reportUnmatched: false diff --git a/.vortex/installer/phpunit.xml b/.vortex/installer/phpunit.xml new file mode 100644 index 000000000..ead316b26 --- /dev/null +++ b/.vortex/installer/phpunit.xml @@ -0,0 +1,37 @@ + + + + + tests/phpunit + + + + + + src + + + + + + + + + + diff --git a/.vortex/installer/rector.php b/.vortex/installer/rector.php new file mode 100644 index 000000000..27de71c0a --- /dev/null +++ b/.vortex/installer/rector.php @@ -0,0 +1,61 @@ +paths([ + __DIR__ . '/**', + ]); + + $rectorConfig->sets([ + SetList::PHP_80, + SetList::PHP_81, + SetList::CODE_QUALITY, + SetList::CODING_STYLE, + SetList::DEAD_CODE, + SetList::INSTANCEOF, + SetList::TYPE_DECLARATION, + ]); + + $rectorConfig->skip([ + // Rules added by Rector's rule sets. + CountArrayToEmptyArrayComparisonRector::class, + DisallowedEmptyRuleFixerRector::class, + InlineArrayReturnAssignRector::class, + NewlineAfterStatementRector::class, + NewlineBeforeNewAssignSetRector::class, + RemoveAlwaysTrueIfConditionRector::class, + SimplifyEmptyCheckOnEmptyArrayRector::class, + // Dependencies. + '*/vendor/*', + '*/node_modules/*', + ]); + + $rectorConfig->fileExtensions([ + 'php', + 'inc', + ]); + + $rectorConfig->importNames(TRUE, FALSE); + $rectorConfig->importShortClasses(FALSE); +}; diff --git a/.vortex/installer/src/Command/InstallCommand.php b/.vortex/installer/src/Command/InstallCommand.php new file mode 100644 index 000000000..27fcfbef6 --- /dev/null +++ b/.vortex/installer/src/Command/InstallCommand.php @@ -0,0 +1,2349 @@ +setName('Vortex CLI installer') + ->addArgument('path', InputArgument::OPTIONAL, 'Destination directory. Optional. Defaults to the current directory.') + ->setHelp($this->printHelp()); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output): int { + return $this->main($input, $output); + } + + /** + * Main functionality. + */ + protected function main(InputInterface $input, OutputInterface $output): int { + self::$currentDir = getcwd(); + + $this->initConfig($input); + + if ($this->getConfig('help')) { + $output->write($this->printHelp()); + + return self::EXIT_SUCCESS; + } + + $this->checkRequirements(); + + $this->printHeader(); + + $this->collectAnswers(); + + if ($this->askShouldProceed()) { + $this->install(); + + $this->printFooter(); + } + else { + $this->printAbort(); + } + + return self::EXIT_SUCCESS; + } + + protected function checkRequirements() { + $this->commandExists('git'); + $this->commandExists('tar'); + $this->commandExists('composer'); + } + + protected function install() { + $this->download(); + + $this->prepareDestination(); + + $this->replaceTokens(); + + $this->copyFiles(); + + $this->processDemo(); + } + + protected function prepareDestination() { + $dst = $this->getDstDir(); + + if (!is_dir($dst)) { + $this->status(sprintf('Creating destination directory "%s".', $dst), self::INSTALLER_STATUS_MESSAGE, FALSE); + mkdir($dst); + if (!is_writable($dst)) { + throw new \RuntimeException(sprintf('Destination directory "%s" is not writable.', $dst)); + } + print ' '; + $this->status('Done', self::INSTALLER_STATUS_SUCCESS); + } + + if (is_readable($dst . '/.git')) { + $this->status(sprintf('Git repository exists in "%s" - skipping initialisation.', $dst), self::INSTALLER_STATUS_MESSAGE, FALSE); + } + else { + $this->status(sprintf('Initialising Git repository in directory "%s".', $dst), self::INSTALLER_STATUS_MESSAGE, FALSE); + $this->doExec(sprintf('git --work-tree="%s" --git-dir="%s/.git" init > /dev/null', $dst, $dst)); + if (!is_readable($dst . '/.git')) { + throw new \RuntimeException(sprintf('Unable to init git project in directory "%s".', $dst)); + } + } + print ' '; + $this->status('Done', self::INSTALLER_STATUS_SUCCESS); + } + + /** + * Replace tokens. + */ + protected function replaceTokens() { + $dir = $this->getConfig('VORTEX_INSTALL_TMP_DIR'); + + $this->status('Replacing tokens ', self::INSTALLER_STATUS_MESSAGE, FALSE); + + $processors = [ + 'webroot', + 'profile', + 'provision_use_profile', + 'database_download_source', + 'database_image', + 'override_existing_db', + 'deploy_type', + 'preserve_acquia', + 'preserve_lagoon', + 'preserve_ftp', + 'preserve_renovatebot', + 'string_tokens', + 'preserve_doc_comments', + 'demo_mode', + 'preserve_vortex_info', + 'vortex_internal', + 'enable_commented_code', + ]; + + foreach ($processors as $name) { + $this->processAnswer($name, $dir); + $this->printTick($name); + } + + print ' '; + $this->status('Done', self::INSTALLER_STATUS_SUCCESS); + } + + protected function copyFiles() { + $src = $this->getConfig('VORTEX_INSTALL_TMP_DIR'); + $dst = $this->getDstDir(); + + // Due to the way symlinks can be ordered, we cannot copy files one-by-one + // into destination directory. Instead, we are removing all ignored files + // and empty directories, making the src directory "clean", and then + // recursively copying the whole directory. + $all = static::scandirRecursive($src, static::ignorePaths(), TRUE); + $files = static::scandirRecursive($src); + $valid_files = static::scandirRecursive($src, static::ignorePaths()); + $dirs = array_diff($all, $valid_files); + $ignored_files = array_diff($files, $valid_files); + + $this->status('Copying files', self::INSTALLER_STATUS_DEBUG); + + foreach ($valid_files as $filename) { + $relative_file = str_replace($src . DIRECTORY_SEPARATOR, '.' . DIRECTORY_SEPARATOR, (string) $filename); + + if (static::isInternalPath($relative_file)) { + $this->status(sprintf('Skipped file %s as an internal Vortex file.', $relative_file), self::INSTALLER_STATUS_DEBUG); + unlink($filename); + continue; + } + } + + // Remove skipped files. + foreach ($ignored_files as $skipped_file) { + if (is_readable($skipped_file)) { + unlink($skipped_file); + } + } + + // Remove empty directories. + foreach ($dirs as $dir) { + static::rmdirRecursiveEmpty($dir); + } + + // Src directory is now "clean" - copy it to dst directory. + if (is_dir($src) && !static::dirIsEmpty($src)) { + static::copyRecursive($src, $dst, 0755, FALSE); + } + + // Special case for .env.local as it may exist. + if (!file_exists($dst . '/.env.local')) { + static::copyRecursive($dst . '/.env.local.default', $dst . '/.env.local', 0755, FALSE); + } + } + + protected function processDemo() { + if (empty($this->getConfig('VORTEX_INSTALL_DEMO')) || !empty($this->getConfig('VORTEX_INSTALL_DEMO_SKIP'))) { + return; + } + + // Reload variables from destination's .env. + static::loadDotenv($this->getDstDir() . '/.env'); + + $url = static::getenvOrDefault('VORTEX_DB_DOWNLOAD_CURL_URL'); + if (empty($url)) { + return; + } + + $data_dir = $this->getDstDir() . DIRECTORY_SEPARATOR . static::getenvOrDefault('VORTEX_DB_DIR', './.data'); + $file = static::getenvOrDefault('VORTEX_DB_FILE', 'db.sql'); + + $this->status(sprintf('No database dump file found in "%s" directory. Downloading DEMO database from %s.', $data_dir, $url), self::INSTALLER_STATUS_MESSAGE, FALSE); + + if (!file_exists($data_dir)) { + mkdir($data_dir); + } + + $this->doExec(sprintf('curl -s -L "%s" -o "%s/%s"', $url, $data_dir, $file), $output, $code); + + if ($code !== 0) { + throw new \RuntimeException(sprintf('Unable to download demo database from "%s".', $url)); + } + + print ' '; + $this->status('Done', self::INSTALLER_STATUS_SUCCESS); + } + + protected static function copyRecursive($source, $dest, $permissions = 0755, $copy_empty_dirs = FALSE): bool { + $parent = dirname((string) $dest); + + if (!is_dir($parent)) { + mkdir($parent, $permissions, TRUE); + } + + // Note that symlink target must exist. + if (is_link($source)) { + // Changing dir symlink will be relevant to the current destination's file + // directory. + $cur_dir = getcwd(); + chdir($parent); + $ret = TRUE; + if (!is_readable(basename((string) $dest))) { + $ret = symlink(readlink($source), basename((string) $dest)); + } + chdir($cur_dir); + + return $ret; + } + + if (is_file($source)) { + $ret = copy($source, $dest); + if ($ret) { + chmod($dest, fileperms($source)); + } + + return $ret; + } + + if (!is_dir($dest) && $copy_empty_dirs) { + mkdir($dest, $permissions, TRUE); + } + + $dir = dir($source); + while ($dir && FALSE !== $entry = $dir->read()) { + if ($entry == '.' || $entry == '..') { + continue; + } + static::copyRecursive(sprintf('%s/%s', $source, $entry), sprintf('%s/%s', $dest, $entry), $permissions, FALSE); + } + + $dir && $dir->close(); + + return TRUE; + } + + protected function gitFileIsTracked($path, string $dir): bool { + if (is_dir($dir . DIRECTORY_SEPARATOR . '.git')) { + $cwd = getcwd(); + chdir($dir); + $this->doExec(sprintf('git ls-files --error-unmatch "%s" 2>&1 >/dev/null', $path), $output, $code); + chdir($cwd); + + return $code === 0; + } + + return FALSE; + } + + protected function drupalCoreProfiles(): array { + return [ + 'standard', + 'minimal', + 'testing', + 'demo_umami', + ]; + } + + /** + * Process answers. + */ + protected function processAnswer($name, $dir) { + return $this->executeCallback('process', $name, $dir); + } + + protected function processProfile(string $dir) { + $webroot = $this->getAnswer('webroot'); + // For core profiles - remove custom profile and direct links to it. + if (in_array($this->getAnswer('profile'), $this->drupalCoreProfiles())) { + static::rmdirRecursive(sprintf('%s/%s/profiles/your_site_profile', $dir, $webroot)); + static::rmdirRecursive(sprintf('%s/%s/profiles/custom/your_site_profile', $dir, $webroot)); + static::dirReplaceContent($webroot . '/profiles/your_site_profile,', '', $dir); + static::dirReplaceContent($webroot . '/profiles/custom/your_site_profile,', '', $dir); + } + static::dirReplaceContent('your_site_profile', $this->getAnswer('profile'), $dir); + } + + protected function processProvisionUseProfile(string $dir) { + if ($this->getAnswer('provision_use_profile') == self::ANSWER_YES) { + static::fileReplaceContent('/VORTEX_PROVISION_USE_PROFILE=.*/', "VORTEX_PROVISION_USE_PROFILE=1", $dir . '/.env'); + $this->removeTokenWithContent('!PROVISION_USE_PROFILE', $dir); + } + else { + static::fileReplaceContent('/VORTEX_PROVISION_USE_PROFILE=.*/', "VORTEX_PROVISION_USE_PROFILE=0", $dir . '/.env'); + $this->removeTokenWithContent('PROVISION_USE_PROFILE', $dir); + } + } + + protected function processDatabaseDownloadSource(string $dir) { + $type = $this->getAnswer('database_download_source'); + static::fileReplaceContent('/VORTEX_DB_DOWNLOAD_SOURCE=.*/', 'VORTEX_DB_DOWNLOAD_SOURCE=' . $type, $dir . '/.env'); + + $types = [ + 'curl', + 'ftp', + 'acquia', + 'lagoon', + 'container_registry', + 'none', + ]; + + foreach ($types as $t) { + $token = 'VORTEX_DB_DOWNLOAD_SOURCE_' . strtoupper($t); + if ($t == $type) { + $this->removeTokenWithContent('!' . $token, $dir); + } + else { + $this->removeTokenWithContent($token, $dir); + } + } + } + + protected function processDatabaseImage(string $dir) { + $image = $this->getAnswer('database_image'); + static::fileReplaceContent('/VORTEX_DB_IMAGE=.*/', 'VORTEX_DB_IMAGE=' . $image, $dir . '/.env'); + + if ($image) { + $this->removeTokenWithContent('!VORTEX_DB_IMAGE', $dir); + } + else { + $this->removeTokenWithContent('VORTEX_DB_IMAGE', $dir); + } + } + + protected function processOverrideExistingDb(string $dir) { + if ($this->getAnswer('override_existing_db') == self::ANSWER_YES) { + static::fileReplaceContent('/VORTEX_PROVISION_OVERRIDE_DB=.*/', "VORTEX_PROVISION_OVERRIDE_DB=1", $dir . '/.env'); + } + else { + static::fileReplaceContent('/VORTEX_PROVISION_OVERRIDE_DB=.*/', "VORTEX_PROVISION_OVERRIDE_DB=0", $dir . '/.env'); + } + } + + protected function processDeployType(string $dir) { + $type = $this->getAnswer('deploy_type'); + if ($type != 'none') { + static::fileReplaceContent('/VORTEX_DEPLOY_TYPES=.*/', 'VORTEX_DEPLOY_TYPES=' . $type, $dir . '/.env'); + + if (!str_contains((string) $type, 'artifact')) { + @unlink($dir . '/.gitignore.deployment'); + @unlink($dir . '/.gitignore.artifact'); + } + + $this->removeTokenWithContent('!DEPLOYMENT', $dir); + } + else { + @unlink($dir . '/docs/deployment.md'); + @unlink($dir . '/.gitignore.deployment'); + @unlink($dir . '/.gitignore.artifact'); + $this->removeTokenWithContent('DEPLOYMENT', $dir); + } + } + + protected function processPreserveAcquia(string $dir) { + if ($this->getAnswer('preserve_acquia') == self::ANSWER_YES) { + $this->removeTokenWithContent('!ACQUIA', $dir); + } + else { + static::rmdirRecursive($dir . '/hooks'); + $webroot = $this->getAnswer('webroot'); + @unlink(sprintf('%s/%s/sites/default/includes/providers/settings.acquia.php', $dir, $webroot)); + $this->removeTokenWithContent('ACQUIA', $dir); + } + } + + protected function processPreserveLagoon(string $dir) { + if ($this->getAnswer('preserve_lagoon') == self::ANSWER_YES) { + $this->removeTokenWithContent('!LAGOON', $dir); + } + else { + @unlink($dir . '/drush/sites/lagoon.site.yml'); + @unlink($dir . '/.lagoon.yml'); + @unlink($dir . '/.github/workflows/close-pull-request.yml'); + $webroot = $this->getAnswer('webroot'); + @unlink(sprintf('%s/%s/sites/default/includes/providers/settings.lagoon.php', $dir, $webroot)); + $this->removeTokenWithContent('LAGOON', $dir); + } + } + + protected function processPreserveFtp(string $dir) { + if ($this->getAnswer('preserve_ftp') == self::ANSWER_YES) { + $this->removeTokenWithContent('!FTP', $dir); + } + else { + $this->removeTokenWithContent('FTP', $dir); + } + } + + protected function processPreserveRenovatebot(string $dir) { + if ($this->getAnswer('preserve_renovatebot') == self::ANSWER_YES) { + $this->removeTokenWithContent('!RENOVATEBOT', $dir); + } + else { + @unlink($dir . '/renovate.json'); + $this->removeTokenWithContent('RENOVATEBOT', $dir); + } + } + + protected function processStringTokens(string $dir) { + $machine_name_hyphenated = str_replace('_', '-', (string) $this->getAnswer('machine_name')); + $machine_name_camel_cased = static::toCamelCase($this->getAnswer('machine_name'), TRUE); + $module_prefix_camel_cased = static::toCamelCase($this->getAnswer('module_prefix'), TRUE); + $module_prefix_uppercase = strtoupper((string) $module_prefix_camel_cased); + $theme_camel_cased = static::toCamelCase($this->getAnswer('theme'), TRUE); + $vortex_version_urlencoded = str_replace('-', '--', (string) $this->getConfig('VORTEX_VERSION')); + $url = $this->getAnswer('url'); + $host = parse_url((string) $url, PHP_URL_HOST); + $domain = ($host) ? $host : $url; + $domain_non_www = str_starts_with((string) $domain, "www.") ? substr((string) $domain, 4) : $domain; + $webroot = $this->getAnswer('webroot'); + + // @formatter:off + // phpcs:disable Generic.Functions.FunctionCallArgumentSpacing.TooMuchSpaceAfterComma + // phpcs:disable Drupal.WhiteSpace.Comma.TooManySpaces + static::dirReplaceContent('your_site_theme', $this->getAnswer('theme'), $dir); + static::dirReplaceContent('YourSiteTheme', $theme_camel_cased, $dir); + static::dirReplaceContent('your_org', $this->getAnswer('org_machine_name'), $dir); + static::dirReplaceContent('YOURORG', $this->getAnswer('org'), $dir); + static::dirReplaceContent('www.your-site-url.example', $domain, $dir); + static::dirReplaceContent('your-site-url.example', $domain_non_www, $dir); + static::dirReplaceContent('ys_core', $this->getAnswer('module_prefix') . '_core', $dir . sprintf('/%s/modules/custom', $webroot)); + static::dirReplaceContent('ys_core', $this->getAnswer('module_prefix') . '_core', $dir . sprintf('/%s/themes/custom', $webroot)); + static::dirReplaceContent('ys_core', $this->getAnswer('module_prefix') . '_core', $dir . '/scripts/custom'); + static::dirReplaceContent('YsCore', $module_prefix_camel_cased . 'Core', $dir . sprintf('/%s/modules/custom', $webroot)); + static::dirReplaceContent('YSCODE', $module_prefix_uppercase, $dir); + static::dirReplaceContent('your-site', $machine_name_hyphenated, $dir); + static::dirReplaceContent('your_site', $this->getAnswer('machine_name'), $dir); + static::dirReplaceContent('YOURSITE', $this->getAnswer('name'), $dir); + static::dirReplaceContent('YourSite', $machine_name_camel_cased, $dir); + + static::replaceStringFilename('YourSiteTheme', $theme_camel_cased, $dir); + static::replaceStringFilename('your_site_theme', $this->getAnswer('theme'), $dir); + static::replaceStringFilename('YourSite', $machine_name_camel_cased, $dir); + static::replaceStringFilename('ys_core', $this->getAnswer('module_prefix') . '_core', $dir . sprintf('/%s/modules/custom', $webroot)); + static::replaceStringFilename('YsCore', $module_prefix_camel_cased . 'Core', $dir . sprintf('/%s/modules/custom', $webroot)); + static::replaceStringFilename('your_org', $this->getAnswer('org_machine_name'), $dir); + static::replaceStringFilename('your_site', $this->getAnswer('machine_name'), $dir); + + static::dirReplaceContent('VORTEX_VERSION_URLENCODED', $vortex_version_urlencoded, $dir); + static::dirReplaceContent('VORTEX_VERSION', $this->getConfig('VORTEX_VERSION'), $dir); + // @formatter:on + // phpcs:enable Generic.Functions.FunctionCallArgumentSpacing.TooMuchSpaceAfterComma + // phpcs:enable Drupal.WhiteSpace.Comma.TooManySpaces + } + + protected function processPreserveDocComments(string $dir) { + if ($this->getAnswer('preserve_doc_comments') == self::ANSWER_YES) { + // Replace special "#: " comments with normal "#" comments. + static::dirReplaceContent('#:', '#', $dir); + } + else { + $this->removeTokenLine('#:', $dir); + } + } + + protected function processDemoMode(string $dir) { + // Only discover demo mode if not explicitly set. + if (is_null($this->getConfig('VORTEX_INSTALL_DEMO'))) { + if ($this->getAnswer('provision_use_profile') == self::ANSWER_NO) { + $download_source = $this->getAnswer('database_download_source'); + $db_file = static::getenvOrDefault('VORTEX_DB_DIR', './.data') . DIRECTORY_SEPARATOR . static::getenvOrDefault('VORTEX_DB_FILE', 'db.sql'); + $has_comment = static::fileContains('to allow to demonstrate how Vortex works without', $this->getDstDir() . '/.env'); + + // Enable Vortex demo mode if download source is file AND + // there is no downloaded file present OR if there is a demo comment in + // destination .env file. + if ($download_source != 'container_registry') { + if ($has_comment || !file_exists($db_file)) { + $this->setConfig('VORTEX_INSTALL_DEMO', TRUE); + } + else { + $this->setConfig('VORTEX_INSTALL_DEMO', FALSE); + } + } + elseif ($has_comment || $download_source == 'container_registry') { + $this->setConfig('VORTEX_INSTALL_DEMO', TRUE); + } + else { + $this->setConfig('VORTEX_INSTALL_DEMO', FALSE); + } + } + else { + $this->setConfig('VORTEX_INSTALL_DEMO', FALSE); + } + } + + if (!$this->getConfig('VORTEX_INSTALL_DEMO')) { + $this->removeTokenWithContent('DEMO', $dir); + } + } + + protected function processPreserveVortexInfo(string $dir) { + if ($this->getAnswer('preserve_vortex_info') == self::ANSWER_NO) { + // Remove code required for Vortex maintenance. + $this->removeTokenWithContent('VORTEX_DEV', $dir); + + // Remove all other comments. + $this->removeTokenLine('#;', $dir); + } + } + + protected function processVortexInternal(string $dir) { + if (file_exists($dir . DIRECTORY_SEPARATOR . 'README.dist.md')) { + rename($dir . DIRECTORY_SEPARATOR . 'README.dist.md', $dir . DIRECTORY_SEPARATOR . 'README.md'); + } + + // Remove Vortex internal files. + static::rmdirRecursive($dir . DIRECTORY_SEPARATOR . '.vortex'); + + @unlink($dir . '/.github/FUNDING.yml'); + @unlink($dir . 'CODE_OF_CONDUCT.md'); + @unlink($dir . 'CONTRIBUTING.md'); + @unlink($dir . 'LICENSE'); + @unlink($dir . 'SECURITY.md'); + + // Remove Vortex internal GHAs. + foreach (glob($dir . '/.github/workflows/vortex-*.yml') as $file) { + @unlink($file); + } + + // Remove other unhandled tokenized comments. + $this->removeTokenLine('#;<', $dir); + $this->removeTokenLine('#;>', $dir); + } + + protected function processEnableCommentedCode(string $dir) { + // Enable_commented_code. + static::dirReplaceContent('##### ', '', $dir); + } + + protected function processWebroot(string $dir) { + $new_name = $this->getAnswer('webroot', 'web'); + + if ($new_name != 'web') { + static::dirReplaceContent('web/', $new_name . '/', $dir); + static::dirReplaceContent('web\/', $new_name . '\/', $dir); + static::dirReplaceContent(': web', ': ' . $new_name, $dir); + static::dirReplaceContent('=web', '=' . $new_name, $dir); + static::dirReplaceContent('!web', '!' . $new_name, $dir); + static::dirReplaceContent('/\/web\//', '/' . $new_name . '/', $dir); + rename($dir . DIRECTORY_SEPARATOR . 'web', $dir . DIRECTORY_SEPARATOR . $new_name); + } + } + + /** + * Download Vortex source files. + */ + protected function download() { + if ($this->getConfig('VORTEX_INSTALL_LOCAL_REPO')) { + $this->downloadLocal(); + } + else { + $this->downloadRemote(); + } + } + + protected function downloadLocal() { + $dst = $this->getConfig('VORTEX_INSTALL_TMP_DIR'); + $repo = $this->getConfig('VORTEX_INSTALL_LOCAL_REPO'); + $ref = $this->getConfig('VORTEX_INSTALL_COMMIT'); + + $this->status(sprintf('Downloading Vortex from the local repository "%s" at ref "%s".', $repo, $ref), self::INSTALLER_STATUS_MESSAGE, FALSE); + + $command = sprintf('git --git-dir="%s/.git" --work-tree="%s" archive --format=tar "%s" | tar xf - -C "%s"', $repo, $repo, $ref, $dst); + $this->doExec($command, $output, $code); + + $this->status(implode(PHP_EOL, $output), self::INSTALLER_STATUS_DEBUG); + + if ($code != 0) { + throw new \RuntimeException(implode(PHP_EOL, $output)); + } + + $this->status(sprintf('Downloaded to "%s".', $dst), self::INSTALLER_STATUS_DEBUG); + + print ' '; + $this->status('Done', self::INSTALLER_STATUS_SUCCESS); + } + + protected function downloadRemote() { + $dst = $this->getConfig('VORTEX_INSTALL_TMP_DIR'); + $org = 'drevops'; + $project = 'vortex'; + $ref = $this->getConfig('VORTEX_INSTALL_COMMIT'); + $release_prefix = $this->getConfig('VORTEX_VERSION'); + + if ($ref == 'HEAD') { + $release_prefix = $release_prefix == 'develop' ? NULL : $release_prefix; + $ref = $this->findLatestVortexRelease($org, $project, $release_prefix); + $this->setConfig('VORTEX_VERSION', $ref); + } + + $url = sprintf('https://github.com/%s/%s/archive/%s.tar.gz', $org, $project, $ref); + $this->status(sprintf('Downloading Vortex from the remote repository "%s" at ref "%s".', $url, $ref), self::INSTALLER_STATUS_MESSAGE, FALSE); + $this->doExec(sprintf('curl -sS -L "%s" | tar xzf - -C "%s" --strip 1', $url, $dst), $output, $code); + + if ($code != 0) { + throw new \RuntimeException(implode(PHP_EOL, $output)); + } + + $this->status(sprintf('Downloaded to "%s".', $dst), self::INSTALLER_STATUS_DEBUG); + + $this->status('Done', self::INSTALLER_STATUS_SUCCESS); + } + + protected function findLatestVortexRelease($org, $project, $release_prefix) { + $release_url = sprintf('https://api.github.com/repos/%s/%s/releases', $org, $project); + $release_contents = file_get_contents($release_url, FALSE, stream_context_create([ + 'http' => ['method' => 'GET', 'header' => ['User-Agent: PHP']], + ])); + + if (!$release_contents) { + throw new \RuntimeException(sprintf('Unable to download release information from "%s".', $release_url)); + } + + $records = json_decode($release_contents, TRUE); + foreach ($records as $record) { + if (isset($record['tag_name']) && ($release_prefix && str_contains((string) $record['tag_name'], (string) $release_prefix) || !$release_prefix)) { + return $record['tag_name']; + } + } + + return NULL; + } + + /** + * Gather answers. + * + * This is how the values pipeline works for a variable: + * 1. Read from .env + * 2. Read from environment + * 3. Read from user: default->discovered->answer->normalisation->save answer + * 4. Use answers for processing, including writing values into correct + * variables in .env. + */ + protected function collectAnswers() { + // Set answers that may be used in other answers' discoveries. + $this->setAnswer('webroot', $this->discoverValue('webroot')); + + // @formatter:off + // phpcs:disable Generic.Functions.FunctionCallArgumentSpacing.TooMuchSpaceAfterComma + // phpcs:disable Drupal.WhiteSpace.Comma.TooManySpaces + $this->askForAnswer('name', 'What is your site name?'); + $this->askForAnswer('machine_name', 'What is your site machine name?'); + $this->askForAnswer('org', 'What is your organization name'); + $this->askForAnswer('org_machine_name', 'What is your organization machine name?'); + $this->askForAnswer('module_prefix', 'What is your project-specific module prefix?'); + $this->askForAnswer('profile', 'What is your custom profile machine name (leave empty to use "standard" profile)?'); + $this->askForAnswer('theme', 'What is your theme machine name?'); + $this->askForAnswer('url', 'What is your site public URL?'); + $this->askForAnswer('webroot', 'Web root (web, docroot)?'); + + $this->askForAnswer('provision_use_profile', 'Do you want to install from profile (leave empty or "n" for using database?'); + + if ($this->getAnswer('provision_use_profile') == self::ANSWER_YES) { + $this->setAnswer('database_download_source', 'none'); + $this->setAnswer('database_image', ''); + } + else { + $this->askForAnswer('database_download_source', "Where does the database dump come from into every environment:\n - [u]rl\n - [f]tp\n - [a]cquia backup\n - [d]ocker registry?"); + + if ($this->getAnswer('database_download_source') != 'container_registry') { + // Note that "database_store_type" is a pseudo-answer - it is only used + // to improve UX and is not exposed as a variable (although has default, + // discovery and normalisation callbacks). + $this->askForAnswer('database_store_type', ' When developing locally, do you want to import the database dump from the [f]ile or store it imported in the [d]ocker image for faster builds?'); + } + + if ($this->getAnswer('database_store_type') == 'file') { + $this->setAnswer('database_image', ''); + } + else { + $this->askForAnswer('database_image', ' What is your database image name and a tag (e.g. drevops/drevops-mariadb-drupal-data:latest)?'); + } + } + // @formatter:on + // phpcs:enable Generic.Functions.FunctionCallArgumentSpacing.TooMuchSpaceAfterComma + // phpcs:enable Drupal.WhiteSpace.Comma.TooManySpaces + + $this->askForAnswer('override_existing_db', 'Do you want to override existing database in the environment?'); + + $this->askForAnswer('deploy_type', 'How do you deploy your code to the hosting ([w]ebhook call, [c]ode artifact, [d]ocker image, [l]agoon, [n]one as a comma-separated list)?'); + + if ($this->getAnswer('database_download_source') != 'ftp') { + $this->askForAnswer('preserve_ftp', 'Do you want to keep FTP integration?'); + } + else { + $this->setAnswer('preserve_ftp', self::ANSWER_YES); + } + + if ($this->getAnswer('database_download_source') != 'acquia') { + $this->askForAnswer('preserve_acquia', 'Do you want to keep Acquia Cloud integration?'); + } + else { + $this->setAnswer('preserve_acquia', self::ANSWER_YES); + } + + $this->askForAnswer('preserve_lagoon', 'Do you want to keep Amazee.io Lagoon integration?'); + + $this->askForAnswer('preserve_renovatebot', 'Do you want to keep RenovateBot integration?'); + + $this->askForAnswer('preserve_doc_comments', 'Do you want to keep detailed documentation in comments?'); + $this->askForAnswer('preserve_vortex_info', 'Do you want to keep all Vortex information?'); + + $this->printSummary(); + + if ($this->isInstallDebug()) { + $this->printBox($this->formatValuesList($this->getAnswers(), '', 80 - 6), 'DEBUG RESOLVED ANSWERS'); + } + } + + protected function askShouldProceed(): bool { + $proceed = self::ANSWER_YES; + + if (!$this->isQuiet()) { + $proceed = $this->ask(sprintf('Proceed with installing Vortex into your project\'s directory "%s"? (Y,n)', $this->getDstDir()), $proceed, TRUE); + } + + // Kill-switch to not proceed with install. If false, the install will not + // proceed despite the answer received above. + if (!$this->getConfig('VORTEX_INSTALL_PROCEED')) { + $proceed = self::ANSWER_NO; + } + + return strtolower((string) $proceed) === self::ANSWER_YES; + } + + protected function askForAnswer($name, $question) { + $discovered = $this->discoverValue($name); + $answer = $this->ask($question, $discovered); + $answer = $this->normaliseAnswer($name, $answer); + + $this->setAnswer($name, $answer); + } + + protected function ask($question, $default, $close_handle = FALSE) { + if ($this->isQuiet()) { + return $default; + } + + $question = sprintf('> %s [%s] ', $question, $default); + + $this->out($question, 'question', FALSE); + $handle = $this->getStdinHandle(); + $answer = trim(fgets($handle)); + + if ($close_handle) { + $this->closeStdinHandle(); + } + + return empty($answer) ? $default : $answer; + } + + /** + * Get installer configuration. + * + * Installer config is a config of this installer script. For configs of the + * project being installed, @see get_answer(). + * + * @see init_config() + */ + protected function getConfig($name, $default = NULL) { + global $_config; + + return $_config[$name] ?? $default; + } + + /** + * Set installer configuration. + * + * Installer config is a config of this installer script. For configs of the + * project being installed, @see set_answer(). + * + * @see init_config() + */ + protected function setConfig($name, $value) { + global $_config; + + if (!is_null($value)) { + $_config[$name] = $value; + } + } + + /** + * Get a named option from discovered answers for the project bing installed. + */ + protected function getAnswer($name, $default = NULL) { + global $_answers; + + return $_answers[$name] ?? $default; + } + + /** + * Set a named option for discovered answers for the project bing installed. + */ + protected function setAnswer($name, $value) { + global $_answers; + $_answers[$name] = $value; + } + + /** + * Get all options from discovered answers for the project bing installed. + */ + protected function getAnswers() { + global $_answers; + + return $_answers; + } + + /** + * Init all config. + */ + protected function initConfig($input) { + $this->initCliArgsAndOptions($input); + + static::loadDotenv($this->getDstDir() . '/.env'); + + $this->initInstallerConfig(); + } + + /** + * Initialise CLI options. + */ + protected function initCliArgsAndOptions($input) { + $arg = $input->getArguments(); + $options = $input->getOptions(); + + if (!empty($options['help'])) { + $this->setConfig('help', TRUE); + } + + if (!empty($options['quiet'])) { + $this->setConfig('quiet', TRUE); + } + + if (!empty($options['no-ansi'])) { + $this->setConfig('ANSI', FALSE); + } + else { + // On Windows, default to no ANSI, except in ANSICON and ConEmu. + // Everywhere else, default to ANSI if stdout is a terminal. + $is_ansi = (DIRECTORY_SEPARATOR === '\\') + ? (FALSE !== getenv('ANSICON') || 'ON' === getenv('ConEmuANSI')) + : (function_exists('posix_isatty') && posix_isatty(1)); + $this->setConfig('ANSI', $is_ansi); + } + + if (!empty($arg['path'])) { + $this->setConfig('VORTEX_INSTALL_DST_DIR', $arg['path']); + } + else { + $this->setConfig('VORTEX_INSTALL_DST_DIR', static::getenvOrDefault('VORTEX_INSTALL_DST_DIR', self::$currentDir)); + } + } + + /** + * Instantiate installer configuration from environment variables. + * + * Installer configuration is a set of internal installer script variables, + * read from the environment variables. These environment variables are not + * read directly in any operations of this installer script. Instead, these + * environment variables are accessible with get_installer_config(). + * + * For simplicity of naming, internal installer config variables are matching + * environment variables names. + */ + protected function initInstallerConfig() { + // Internal version of Vortex. + $this->setConfig('VORTEX_VERSION', static::getenvOrDefault('VORTEX_VERSION', 'develop')); + // Flag to display install debug information. + $this->setConfig('VORTEX_INSTALL_DEBUG', (bool) static::getenvOrDefault('VORTEX_INSTALL_DEBUG', FALSE)); + // Flag to proceed with installation. If FALSE - the installation will only + // print resolved values and will not proceed. + $this->setConfig('VORTEX_INSTALL_PROCEED', (bool) static::getenvOrDefault('VORTEX_INSTALL_PROCEED', TRUE)); + // Temporary directory to download and expand files to. + $this->setConfig('VORTEX_INSTALL_TMP_DIR', static::getenvOrDefault('VORTEX_INSTALL_TMP_DIR', static::tempdir())); + // Path to local Vortex repository. If not provided - remote will be used. + $this->setConfig('VORTEX_INSTALL_LOCAL_REPO', static::getenvOrDefault('VORTEX_INSTALL_LOCAL_REPO')); + // Optional commit to download. If not provided, latest release will be + // downloaded. + $this->setConfig('VORTEX_INSTALL_COMMIT', static::getenvOrDefault('VORTEX_INSTALL_COMMIT', 'HEAD')); + + // Internal flag to enforce DEMO mode. If not set, the demo mode will be + // discovered automatically. + if (!is_null(static::getenvOrDefault('VORTEX_INSTALL_DEMO'))) { + $this->setConfig('VORTEX_INSTALL_DEMO', (bool) static::getenvOrDefault('VORTEX_INSTALL_DEMO')); + } + // Internal flag to skip processing of the demo mode. + $this->setConfig('VORTEX_INSTALL_DEMO_SKIP', (bool) static::getenvOrDefault('VORTEX_INSTALL_DEMO_SKIP', FALSE)); + } + + protected function getDstDir() { + return $this->getConfig('VORTEX_INSTALL_DST_DIR'); + } + + /** + * Shorthand to get the value of whether install should be quiet. + */ + protected function isQuiet() { + return $this->getConfig('quiet'); + } + + /** + * Shorthand to get the value of VORTEX_INSTALL_DEBUG. + */ + protected function isInstallDebug() { + return $this->getConfig('VORTEX_INSTALL_DEBUG'); + } + + /** + * Get default value router. + */ + protected function getDefaultValue($name) { + // Allow to override default values from config variables. + $config_name = strtoupper((string) $name); + + return $this->getConfig($config_name, $this->executeCallback('getDefaultValue', $name)); + } + + protected function getDefaultValueName(): ?string { + return static::toHumanName(static::getenvOrDefault('VORTEX_PROJECT', basename((string) $this->getDstDir()))); + } + + protected function getDefaultValueMachineName(): string { + return static::toMachineName($this->getAnswer('name')); + } + + protected function getDefaultValueOrg(): string { + return $this->getAnswer('name') . ' Org'; + } + + protected function getDefaultValueOrgMachineName(): string { + return static::toMachineName($this->getAnswer('org')); + } + + protected function getDefaultValueModulePrefix(): string|array { + return $this->toAbbreviation($this->getAnswer('machine_name')); + } + + protected function getDefaultValueProfile(): string { + return self::ANSWER_NO; + } + + protected function getDefaultValueTheme() { + return $this->getAnswer('machine_name'); + } + + protected function getDefaultValueUrl(): string { + $value = $this->getAnswer('machine_name'); + $value = str_replace('_', '-', (string) $value); + + return $value . '.com'; + } + + protected function getDefaultValueWebroot(): string { + return 'web'; + } + + protected function getDefaultValueProvisionUseProfile(): string { + return self::ANSWER_NO; + } + + protected function getDefaultValueDatabaseDownloadSource(): string { + return 'curl'; + } + + protected function getDefaultValueDatabaseStoreType(): string { + return 'file'; + } + + protected function getDefaultValueDatabaseImage(): string { + return 'drevops/mariadb-drupal-data:latest'; + } + + protected function getDefaultValueOverrideExistingDb(): string { + return self::ANSWER_NO; + } + + protected function getDefaultValueDeployType(): string { + return 'artifact'; + } + + protected function getDefaultValuePreserveAcquia(): string { + return self::ANSWER_NO; + } + + protected function getDefaultValuePreserveLagoon(): string { + return self::ANSWER_NO; + } + + protected function getDefaultValuePreserveFtp(): string { + return self::ANSWER_NO; + } + + protected function getDefaultValuePreserveRenovatebot(): string { + return self::ANSWER_YES; + } + + protected function getDefaultValuePreserveDocComments(): string { + return self::ANSWER_YES; + } + + protected function getDefaultValuePreserveVortexInfo(): string { + return self::ANSWER_NO; + } + + /** + * Discover value router. + * + * Value discoveries should return NULL if they don't have the resources to + * discover a value. This means that if the value is expected to come from a + * file but the file is not available, the function should return NULL instead + * of a falsy value like FALSE or 0. + */ + protected function discoverValue($name) { + $value = $this->executeCallback('discoverValue', $name); + + return is_null($value) ? $this->getDefaultValue($name) : $value; + } + + protected function discoverValueName(): ?string { + $value = $this->getComposerJsonValue('description'); + if ($value && preg_match('/Drupal \d+ .* of ([0-9a-zA-Z\- ]+) for ([0-9a-zA-Z\- ]+)/', (string) $value, $matches) && !empty($matches[1])) { + return $matches[1]; + } + + return NULL; + } + + protected function discoverValueMachineName(): ?string { + $value = $this->getComposerJsonValue('name'); + if ($value && preg_match('/([^\/]+)\/(.+)/', (string) $value, $matches) && !empty($matches[2])) { + return $matches[2]; + } + + return NULL; + } + + protected function discoverValueOrg(): ?string { + $value = $this->getComposerJsonValue('description'); + if ($value && preg_match('/Drupal \d+ .* of ([0-9a-zA-Z\- ]+) for ([0-9a-zA-Z\- ]+)/', (string) $value, $matches) && !empty($matches[2])) { + return $matches[2]; + } + + return NULL; + } + + protected function discoverValueOrgMachineName(): ?string { + $value = $this->getComposerJsonValue('name'); + if ($value && preg_match('/([^\/]+)\/(.+)/', (string) $value, $matches) && !empty($matches[1])) { + return $matches[1]; + } + + return NULL; + } + + protected function discoverValueModulePrefix(): null|string|array { + $webroot = $this->getAnswer('webroot'); + + $locations = [ + $this->getDstDir() . sprintf('/%s/modules/custom/*_core', $webroot), + $this->getDstDir() . sprintf('/%s/sites/all/modules/custom/*_core', $webroot), + $this->getDstDir() . sprintf('/%s/profiles/*/modules/*_core', $webroot), + $this->getDstDir() . sprintf('/%s/profiles/*/modules/custom/*_core', $webroot), + $this->getDstDir() . sprintf('/%s/profiles/custom/*/modules/*_core', $webroot), + $this->getDstDir() . sprintf('/%s/profiles/custom/*/modules/custom/*_core', $webroot), + ]; + + $name = $this->findMatchingPath($locations); + + if (empty($name)) { + return NULL; + } + + if ($name) { + $name = basename((string) $name); + $name = str_replace('_core', '', $name); + } + + return $name; + } + + protected function discoverValueProfile() { + $webroot = $this->getAnswer('webroot'); + + if ($this->isInstalled()) { + $name = $this->getValueFromDstDotenv('DRUPAL_PROFILE'); + if (!empty($name)) { + return $name; + } + } + + $locations = [ + $this->getDstDir() . sprintf('/%s/profiles/*/*.info', $webroot), + $this->getDstDir() . sprintf('/%s/profiles/*/*.info.yml', $webroot), + $this->getDstDir() . sprintf('/%s/profiles/custom/*/*.info', $webroot), + $this->getDstDir() . sprintf('/%s/profiles/custom/*/*.info.yml', $webroot), + ]; + + $name = $this->findMatchingPath($locations, 'Drupal 10 profile implementation of'); + + if (empty($name)) { + return NULL; + } + + if ($name) { + $name = basename((string) $name); + $name = str_replace(['.info.yml', '.info'], '', $name); + } + + return $name; + } + + protected function discoverValueTheme() { + $webroot = $this->getAnswer('webroot'); + + if ($this->isInstalled()) { + $name = $this->getValueFromDstDotenv('DRUPAL_THEME'); + if (!empty($name)) { + return $name; + } + } + + $locations = [ + $this->getDstDir() . sprintf('/%s/themes/custom/*/*.info', $webroot), + $this->getDstDir() . sprintf('/%s/themes/custom/*/*.info.yml', $webroot), + $this->getDstDir() . sprintf('/%s/sites/all/themes/custom/*/*.info', $webroot), + $this->getDstDir() . sprintf('/%s/sites/all/themes/custom/*/*.info.yml', $webroot), + $this->getDstDir() . sprintf('/%s/profiles/*/themes/custom/*/*.info', $webroot), + $this->getDstDir() . sprintf('/%s/profiles/*/themes/custom/*/*.info.yml', $webroot), + $this->getDstDir() . sprintf('/%s/profiles/custom/*/themes/custom/*/*.info', $webroot), + $this->getDstDir() . sprintf('/%s/profiles/custom/*/themes/custom/*/*.info.yml', $webroot), + ]; + + $name = $this->findMatchingPath($locations); + + if (empty($name)) { + return NULL; + } + + if ($name) { + $name = basename((string) $name); + $name = str_replace(['.info.yml', '.info'], '', $name); + } + + return $name; + } + + protected function discoverValueUrl() { + $webroot = $this->getAnswer('webroot'); + + $origin = NULL; + $path = $this->getDstDir() . sprintf('/%s/sites/default/settings.php', $webroot); + + if (!is_readable($path)) { + return NULL; + } + + $contents = file_get_contents($path); + + // Drupal 8 and 9. + if (preg_match('/\$config\s*\[\'stage_file_proxy.settings\'\]\s*\[\'origin\'\]\s*=\s*[\'"]([^\'"]+)[\'"];/', $contents, $matches)) { + if (!empty($matches[1])) { + $origin = $matches[1]; + } + } + // Drupal 7. + elseif (preg_match('/\$conf\s*\[\'stage_file_proxy_origin\'\]\s*=\s*[\'"]([^\'"]+)[\'"];/', $contents, $matches)) { + if (!empty($matches[1])) { + $origin = $matches[1]; + } + } + if ($origin) { + $origin = parse_url($origin, PHP_URL_HOST); + } + + return empty($origin) ? NULL : $origin; + } + + protected function discoverValueWebroot() { + $webroot = $this->getValueFromDstDotenv('VORTEX_WEBROOT'); + + if (empty($webroot) && $this->isInstalled()) { + // Try from composer.json. + $extra = $this->getComposerJsonValue('extra'); + if (!empty($extra)) { + $webroot = $extra['drupal-scaffold']['drupal-scaffold']['locations']['web-root'] ?? NULL; + } + } + + return $webroot; + } + + protected function discoverValueProvisionUseProfile(): string { + return $this->getValueFromDstDotenv('VORTEX_PROVISION_USE_PROFILE') ? self::ANSWER_YES : self::ANSWER_NO; + } + + protected function discoverValueDatabaseDownloadSource() { + return $this->getValueFromDstDotenv('VORTEX_DB_DOWNLOAD_SOURCE'); + } + + protected function discoverValueDatabaseStoreType(): string { + return $this->discoverValueDatabaseImage() ? 'container_image' : 'file'; + } + + protected function discoverValueDatabaseImage() { + return $this->getValueFromDstDotenv('VORTEX_DB_IMAGE'); + } + + protected function discoverValueOverrideExistingDb(): string { + return $this->getValueFromDstDotenv('VORTEX_PROVISION_OVERRIDE_DB') ? self::ANSWER_YES : self::ANSWER_NO; + } + + protected function discoverValueDeployType() { + return $this->getValueFromDstDotenv('VORTEX_DEPLOY_TYPES'); + } + + protected function discoverValuePreserveAcquia(): ?string { + if (is_readable($this->getDstDir() . '/hooks')) { + return self::ANSWER_YES; + } + $value = $this->getValueFromDstDotenv('VORTEX_DB_DOWNLOAD_SOURCE'); + + if (is_null($value)) { + return NULL; + } + + return $value == 'acquia' ? self::ANSWER_YES : self::ANSWER_NO; + } + + protected function discoverValuePreserveLagoon(): ?string { + if (is_readable($this->getDstDir() . '/.lagoon.yml')) { + return self::ANSWER_YES; + } + + if ($this->getAnswer('deploy_type') == 'lagoon') { + return self::ANSWER_YES; + } + + $value = $this->getValueFromDstDotenv('LAGOON_PROJECT'); + + // Special case - only work with non-empty value as 'LAGOON_PROJECT' + // may not exist in installed site's .env file. + if (empty($value)) { + return NULL; + } + + return self::ANSWER_YES; + } + + protected function discoverValuePreserveFtp(): ?string { + $value = $this->getValueFromDstDotenv('VORTEX_DB_DOWNLOAD_SOURCE'); + if (is_null($value)) { + return NULL; + } + + return $value == 'ftp' ? self::ANSWER_YES : self::ANSWER_NO; + } + + protected function discoverValuePreserveRenovatebot(): ?string { + if (!$this->isInstalled()) { + return NULL; + } + + return is_readable($this->getDstDir() . '/renovate.json') ? self::ANSWER_YES : self::ANSWER_NO; + } + + protected function discoverValuePreserveDocComments(): ?string { + $file = $this->getDstDir() . '/.ahoy.yml'; + if (!is_readable($file)) { + return NULL; + } + + return static::fileContains('Ahoy configuration file', $file) ? self::ANSWER_YES : self::ANSWER_NO; + } + + protected function discoverValuePreserveVortexInfo(): ?string { + $file = $this->getDstDir() . '/.ahoy.yml'; + if (!is_readable($file)) { + return NULL; + } + + return static::fileContains('Comments starting with', $file) ? self::ANSWER_YES : self::ANSWER_NO; + } + + protected function getValueFromDstDotenv($name, $default = NULL) { + // Environment variables always take precedence. + $env_value = static::getenvOrDefault($name, NULL); + if (!is_null($env_value)) { + return $env_value; + } + + $file = $this->getDstDir() . '/.env'; + if (!is_readable($file)) { + return $default; + } + $parsed = static::parseDotenv($file); + + return $parsed ? $parsed[$name] ?? $default : $default; + } + + protected function findMatchingPath($paths, $text = NULL) { + $paths = is_array($paths) ? $paths : [$paths]; + + foreach ($paths as $path) { + $files = glob($path); + if (empty($files)) { + continue; + } + + if (count($files)) { + if (!empty($text)) { + foreach ($files as $file) { + if (static::fileContains($text, $file)) { + return $file; + } + } + } + else { + return reset($files); + } + } + } + + return NULL; + } + + /** + * Check that Vortex is installed for this project. + */ + protected function isInstalled(): bool { + $path = $this->getDstDir() . DIRECTORY_SEPARATOR . 'README.md'; + + return file_exists($path) && preg_match('/badge\/Vortex\-/', file_get_contents($path)); + } + + /** + * Normalisation router. + */ + protected function normaliseAnswer($name, $value) { + $normalised = $this->executeCallback('normaliseAnswer', $name, $value); + + return $normalised ?? $value; + } + + protected function normaliseAnswerName($value): string { + return ucfirst((string) static::toHumanName($value)); + } + + protected function normaliseAnswerMachineName($value): string { + return static::toMachineName($value); + } + + protected function normaliseAnswerOrgMachineName($value): string { + return static::toMachineName($value); + } + + protected function normaliseAnswerModulePrefix($value): string { + return static::toMachineName($value); + } + + protected function normaliseAnswerProfile($value): string { + $profile = static::toMachineName($value); + if (empty($profile) || strtolower($profile) === self::ANSWER_NO) { + $profile = 'standard'; + } + + return $profile; + } + + protected function normaliseAnswerTheme($value): string { + return static::toMachineName($value); + } + + protected function normaliseAnswerUrl($url): string|array { + $url = trim((string) $url); + + return str_replace([' ', '_'], '-', $url); + } + + protected function normaliseAnswerWebroot($value): string { + return strtolower(trim((string) $value, '/')); + } + + protected function normaliseAnswerProvisionUseProfile($value): string { + return strtolower((string) $value) !== self::ANSWER_YES ? self::ANSWER_NO : self::ANSWER_YES; + } + + protected function normaliseAnswerDatabaseDownloadSource($value): string { + $value = strtolower((string) $value); + + return match ($value) { + 'f', 'ftp' => 'ftp', + 'a', 'acquia' => 'acquia', + 'i', 'image', 'container_image', 'container_registry' => 'container_registry', + 'c', 'curl' => 'curl', + default => $this->getDefaultValueDatabaseDownloadSource(), + }; + } + + protected function normaliseAnswerDatabaseStoreType($value): string { + $value = strtolower((string) $value); + + return match ($value) { + 'i', 'image', 'container_image', => 'container_image', + 'f', 'file' => 'file', + default => $this->getDefaultValueDatabaseStoreType(), + }; + } + + protected function normaliseAnswerDatabaseImage($value): string { + $value = static::toMachineName($value, ['-', '/', ':', '.']); + + return str_contains($value, ':') ? $value : $value . ':latest'; + } + + protected function normaliseAnswerOverrideExistingDb($value): string { + return strtolower((string) $value) !== self::ANSWER_YES ? self::ANSWER_NO : self::ANSWER_YES; + } + + protected function normaliseAnswerDeployType($value): ?string { + $types = explode(',', (string) $value); + + $normalised = []; + foreach ($types as $type) { + $type = trim($type); + switch ($type) { + case 'w': + case 'webhook': + $normalised[] = 'webhook'; + break; + + case 'c': + case 'code': + case 'a': + case 'artifact': + $normalised[] = 'artifact'; + break; + + case 'r': + case 'container_registry': + $normalised[] = 'container_registry'; + break; + + case 'l': + case 'lagoon': + $normalised[] = 'lagoon'; + break; + + case 'n': + case 'none': + $normalised[] = 'none'; + break; + } + } + + if (in_array('none', $normalised)) { + return NULL; + } + + $normalised = array_unique($normalised); + + return implode(',', $normalised); + } + + protected function normaliseAnswerPreserveAcquia($value): string { + return strtolower((string) $value) !== self::ANSWER_YES ? self::ANSWER_NO : self::ANSWER_YES; + } + + protected function normaliseAnswerPreserveLagoon($value): string { + return strtolower((string) $value) !== self::ANSWER_YES ? self::ANSWER_NO : self::ANSWER_YES; + } + + protected function normaliseAnswerPreserveFtp($value): string { + return strtolower((string) $value) !== self::ANSWER_YES ? self::ANSWER_NO : self::ANSWER_YES; + } + + protected function normaliseAnswerPreserveRenovatebot($value): string { + return strtolower((string) $value) !== self::ANSWER_YES ? self::ANSWER_NO : self::ANSWER_YES; + } + + protected function normaliseAnswerPreserveDocComments($value): string { + return strtolower((string) $value) !== self::ANSWER_YES ? self::ANSWER_NO : self::ANSWER_YES; + } + + protected function normaliseAnswerPreserveVortexInfo($value): string { + return strtolower((string) $value) !== self::ANSWER_YES ? self::ANSWER_NO : self::ANSWER_YES; + } + + /** + * Print help. + */ + protected function printHelp(): string { + return <<isQuiet()) { + $this->printHeaderQuiet(); + } + else { + $this->printHeaderInteractive(); + } + print PHP_EOL; + } + + protected function printHeaderInteractive() { + $commit = $this->getConfig('VORTEX_INSTALL_COMMIT'); + + $content = ''; + if ($commit == 'HEAD') { + $content .= 'This will install the latest version of Vortex into your project.' . PHP_EOL; + } + else { + $content .= sprintf('This will install Vortex into your project at commit "%s".', $commit) . PHP_EOL; + } + $content .= PHP_EOL; + if ($this->isInstalled()) { + $content .= 'It looks like Vortex is already installed into this project.' . PHP_EOL; + $content .= PHP_EOL; + } + $content .= 'Please answer the questions below to install configuration relevant to your site.' . PHP_EOL; + $content .= 'No changes will be applied until the last confirmation step.' . PHP_EOL; + $content .= PHP_EOL; + $content .= 'Existing committed files will be modified. You will need to resolve changes manually.' . PHP_EOL; + $content .= PHP_EOL; + $content .= 'Press Ctrl+C at any time to exit this installer.' . PHP_EOL; + + $this->printBox($content, 'WELCOME TO VORTEX INTERACTIVE INSTALLER'); + } + + protected function printHeaderQuiet() { + $commit = $this->getConfig('VORTEX_INSTALL_COMMIT'); + + $content = ''; + if ($commit == 'HEAD') { + $content .= 'This will install the latest version of Vortex into your project.' . PHP_EOL; + } + else { + $content .= sprintf('This will install Vortex into your project at commit "%s".', $commit) . PHP_EOL; + } + $content .= PHP_EOL; + if ($this->isInstalled()) { + $content .= 'It looks like Vortex is already installed into this project.' . PHP_EOL; + $content .= PHP_EOL; + } + $content .= 'Vortex installer will try to discover the settings from the environment and will install configuration relevant to your site.' . PHP_EOL; + $content .= PHP_EOL; + $content .= 'Existing committed files will be modified. You will need to resolve changes manually.' . PHP_EOL; + + $this->printBox($content, 'WELCOME TO VORTEX QUIET INSTALLER'); + } + + protected function printSummary() { + $values['Current directory'] = self::$currentDir; + $values['Destination directory'] = $this->getDstDir(); + $values['Vortex version'] = $this->getConfig('VORTEX_VERSION'); + $values['Vortex commit'] = $this->formatNotEmpty($this->getConfig('VORTEX_INSTALL_COMMIT'), 'Latest'); + + $values[] = ''; + $values[] = str_repeat('─', 80 - 2 - 2 * 2); + $values[] = ''; + + $values['Name'] = $this->getAnswer('name'); + $values['Machine name'] = $this->getAnswer('machine_name'); + $values['Organisation'] = $this->getAnswer('org'); + $values['Organisation machine name'] = $this->getAnswer('org_machine_name'); + $values['Module prefix'] = $this->getAnswer('module_prefix'); + $values['Profile'] = $this->getAnswer('profile'); + $values['Theme name'] = $this->getAnswer('theme'); + $values['URL'] = $this->getAnswer('url'); + $values['Web root'] = $this->getAnswer('webroot'); + + $values['Install from profile'] = $this->formatYesNo($this->getAnswer('provision_use_profile')); + + $values['Database download source'] = $this->getAnswer('database_download_source'); + $image = $this->getAnswer('database_image'); + $values['Database store type'] = empty($image) ? 'file' : 'container_image'; + if ($image) { + $values['Database image name'] = $image; + } + + $values['Override existing database'] = $this->formatYesNo($this->getAnswer('override_existing_db')); + $values['Deployment'] = $this->formatNotEmpty($this->getAnswer('deploy_type'), 'Disabled'); + $values['FTP integration'] = $this->formatEnabled($this->getAnswer('preserve_ftp')); + $values['Acquia integration'] = $this->formatEnabled($this->getAnswer('preserve_acquia')); + $values['Lagoon integration'] = $this->formatEnabled($this->getAnswer('preserve_lagoon')); + $values['RenovateBot integration'] = $this->formatEnabled($this->getAnswer('preserve_renovatebot')); + $values['Preserve docs in comments'] = $this->formatYesNo($this->getAnswer('preserve_doc_comments')); + $values['Preserve Vortex comments'] = $this->formatYesNo($this->getAnswer('preserve_vortex_info')); + + $content = $this->formatValuesList($values, '', 80 - 2 - 2 * 2); + + $this->printBox($content, 'INSTALLATION SUMMARY'); + } + + protected function printAbort() { + $this->printBox('Aborting project installation. No files were changed.'); + } + + protected function printFooter() { + print PHP_EOL; + + if ($this->isInstalled()) { + $this->printBox('Finished updating Vortex. Review changes and commit required files.'); + } + else { + $this->printBox('Finished installing Vortex.'); + + $output = ''; + $output .= PHP_EOL; + $output .= 'Next steps:' . PHP_EOL; + $output .= ' cd ' . $this->getDstDir() . PHP_EOL; + $output .= ' git add -A # Add all files.' . PHP_EOL; + $output .= ' git commit -m "Initial commit." # Commit all files.' . PHP_EOL; + $output .= ' ahoy build # Build site.' . PHP_EOL; + $output .= PHP_EOL; + $output .= ' See https://vortex.drevops.com/quickstart'; + $this->status($output, self::INSTALLER_STATUS_SUCCESS, TRUE, FALSE); + } + } + + protected function printTitle($text, $fill = '-', $width = 80, string $cols = '|', $has_content = FALSE) { + $this->printDivider($fill, $width, 'down'); + $lines = explode(PHP_EOL, wordwrap((string) $text, $width - 4, PHP_EOL)); + foreach ($lines as $line) { + $line = ' ' . $line . ' '; + print $cols . str_pad($line, $width - 2, ' ', STR_PAD_BOTH) . $cols . PHP_EOL; + } + $this->printDivider($fill, $width, $has_content ? 'up' : 'both'); + } + + protected function printSubtitle($text, $fill = '=', $width = 80) { + $is_multiline = strlen((string) $text) + 4 >= $width; + if ($is_multiline) { + $this->printTitle($text, $fill, $width, 'both'); + } + else { + $text = ' ' . $text . ' '; + print str_pad($text, $width, $fill, STR_PAD_BOTH) . PHP_EOL; + } + } + + protected function printDivider($fill = '-', $width = 80, $direction = 'none') { + $start = $fill; + $finish = $fill; + switch ($direction) { + case 'up': + $start = '╰'; + $finish = '╯'; + break; + + case 'down': + $start = '╭'; + $finish = '╮'; + break; + + case 'both': + $start = '├'; + $finish = '┤'; + break; + } + + print $start . str_repeat((string) $fill, $width - 2) . $finish . PHP_EOL; + } + + protected function printBox($content, $title = '', $fill = '─', $padding = 2, $width = 80) { + $cols = '│'; + + $max_width = $width - 2 - $padding * 2; + $lines = explode(PHP_EOL, wordwrap(rtrim((string) $content, PHP_EOL), $max_width, PHP_EOL)); + $pad = str_pad(' ', $padding); + $mask = sprintf('%s%s%%-%ss%s%s', $cols, $pad, $max_width, $pad, $cols) . PHP_EOL; + + print PHP_EOL; + if (!empty($title)) { + $this->printTitle($title, $fill, $width); + } + else { + $this->printDivider($fill, $width, 'down'); + } + + array_unshift($lines, ''); + $lines[] = ''; + foreach ($lines as $line) { + printf($mask, $line); + } + + $this->printDivider($fill, $width, 'up'); + print PHP_EOL; + } + + protected function printTick($text = NULL) { + if (!empty($text) && $this->isInstallDebug()) { + print PHP_EOL; + $this->status($text, self::INSTALLER_STATUS_DEBUG, FALSE); + } + else { + $this->status('.', self::INSTALLER_STATUS_MESSAGE, FALSE, FALSE); + } + } + + protected function formatValuesList($values, $delim = '', $width = 80): string { + // Line width - length of delimiters * 2 - 2 spacers. + $line_width = $width - strlen((string) $delim) * 2 - 2; + + // Max name length + spaced on the sides + colon. + $max_name_width = max(array_map('strlen', array_keys($values))) + 2 + 1; + + // Whole width - (name width + 2 delimiters on the sides + 1 delimiter in + // the middle + 2 spaces on the sides + 2 spaces for the center delimiter). + $value_width = $width - ($max_name_width + strlen((string) $delim) * 2 + strlen((string) $delim) + 2 + 2); + + $mask1 = sprintf('%s %%%ds %s %%-%s.%ss %s', $delim, $max_name_width, $delim, $value_width, $value_width, $delim) . PHP_EOL; + $mask2 = sprintf('%s%%2$%ss%s', $delim, $line_width, $delim) . PHP_EOL; + + $output = []; + foreach ($values as $name => $value) { + $is_multiline_value = strlen((string) $value) > $value_width; + + if (is_numeric($name)) { + $name = ''; + $mask = $mask2; + $is_multiline_value = FALSE; + } + else { + $name .= ':'; + $mask = $mask1; + } + + if ($is_multiline_value) { + $lines = array_filter(explode(PHP_EOL, chunk_split((string) $value, $value_width, PHP_EOL))); + $first_line = array_shift($lines); + $output[] = sprintf($mask, $name, $first_line); + foreach ($lines as $line) { + $output[] = sprintf($mask, '', $line); + } + } + else { + $output[] = sprintf($mask, $name, $value); + } + } + + return implode('', $output); + } + + protected function formatEnabled($value): string { + return $value && strtolower((string) $value) !== 'n' ? 'Enabled' : 'Disabled'; + } + + protected function formatYesNo($value): string { + return $value == self::ANSWER_YES ? 'Yes' : 'No'; + } + + protected function formatNotEmpty($value, $default) { + return empty($value) ? $default : $value; + } + + public static function fileContains($needle, $file): int|bool { + if (!is_readable($file)) { + return FALSE; + } + + $content = file_get_contents($file); + + if (static::isRegex($needle)) { + return preg_match($needle, $content); + } + + return str_contains($content, (string) $needle); + } + + protected static function dirContains($needle, string $dir): bool { + $files = static::scandirRecursive($dir, static::ignorePaths()); + foreach ($files as $filename) { + if (static::fileContains($needle, $filename)) { + return TRUE; + } + } + + return FALSE; + } + + protected static function isRegex($str): bool { + if ($str === '' || strlen((string) $str) < 3) { + return FALSE; + } + + return @preg_match($str, '') !== FALSE; + } + + protected static function fileReplaceContent($needle, $replacement, $filename): ?bool { + if (!is_readable($filename) || static::fileIsExcludedFromProcessing($filename)) { + return FALSE; + } + + $content = file_get_contents($filename); + + if (static::isRegex($needle)) { + $replaced = preg_replace($needle, (string) $replacement, $content); + } + else { + $replaced = str_replace($needle, $replacement, $content); + } + if ($replaced != $content) { + file_put_contents($filename, $replaced); + } + + return NULL; + } + + protected static function dirReplaceContent($needle, $replacement, string $dir) { + $files = static::scandirRecursive($dir, static::ignorePaths()); + foreach ($files as $filename) { + static::fileReplaceContent($needle, $replacement, $filename); + } + } + + protected function removeTokenWithContent(string $token, string $dir) { + $files = static::scandirRecursive($dir, static::ignorePaths()); + foreach ($files as $filename) { + static::removeTokenFromFile($filename, '#;< ' . $token, '#;> ' . $token, TRUE); + } + } + + protected function removeTokenLine($token, string $dir) { + if (!empty($token)) { + $files = static::scandirRecursive($dir, static::ignorePaths()); + foreach ($files as $filename) { + static::removeTokenFromFile($filename, $token, NULL); + } + } + } + + public static function removeTokenFromFile($filename, $token_begin, $token_end = NULL, $with_content = FALSE): void { + if (self::fileIsExcludedFromProcessing($filename)) { + return; + } + + $token_end = $token_end ?? $token_begin; + + $content = file_get_contents($filename); + + if ($token_begin != $token_end) { + $token_begin_count = preg_match_all('/' . preg_quote((string) $token_begin) . '/', $content); + $token_end_count = preg_match_all('/' . preg_quote((string) $token_end) . '/', $content); + if ($token_begin_count !== $token_end_count) { + throw new \RuntimeException(sprintf('Invalid begin and end token count in file %s: begin is %s(%s), end is %s(%s).', $filename, $token_begin, $token_begin_count, $token_end, $token_end_count)); + } + } + + $out = []; + $within_token = FALSE; + + $lines = file($filename); + foreach ($lines as $line) { + if (str_contains($line, (string) $token_begin)) { + if ($with_content) { + $within_token = TRUE; + } + continue; + } + elseif (str_contains($line, (string) $token_end)) { + if ($with_content) { + $within_token = FALSE; + } + continue; + } + + if ($with_content && $within_token) { + // Skip content as contents of the token. + continue; + } + + $out[] = $line; + } + + file_put_contents($filename, implode('', $out)); + } + + protected static function replaceStringFilename($search, $replace, string $dir) { + $files = static::scandirRecursive($dir, static::ignorePaths()); + foreach ($files as $filename) { + $new_filename = str_replace($search, $replace, (string) $filename); + if ($filename != $new_filename) { + $new_dir = dirname($new_filename); + if (!is_dir($new_dir)) { + mkdir($new_dir, 0777, TRUE); + } + rename($filename, $new_filename); + } + } + } + + /** + * Recursively scan directory for files. + */ + protected static function scandirRecursive(string $dir, $ignore_paths = [], $include_dirs = FALSE): array { + $discovered = []; + + if (is_dir($dir)) { + $paths = array_diff(scandir($dir), ['.', '..']); + foreach ($paths as $path) { + $path = $dir . '/' . $path; + foreach ($ignore_paths as $ignore_path) { + // Exlude based on sub-path match. + if (str_contains($path, (string) $ignore_path)) { + continue(2); + } + } + if (is_dir($path)) { + if ($include_dirs) { + $discovered[] = $path; + } + $discovered = array_merge($discovered, static::scandirRecursive($path, $ignore_paths, $include_dirs)); + } + else { + $discovered[] = $path; + } + } + } + + return $discovered; + } + + protected function globRecursive($pattern, $flags = 0): array|false { + $files = glob($pattern, $flags | GLOB_BRACE); + foreach (glob(dirname((string) $pattern) . '/{,.}*[!.]', GLOB_BRACE | GLOB_ONLYDIR | GLOB_NOSORT) as $dir) { + $files = array_merge($files, $this->globRecursive($dir . '/' . basename((string) $pattern), $flags)); + } + + return $files; + } + + protected static function ignorePaths(): array { + return array_merge([ + '/.git/', + '/.idea/', + '/vendor/', + '/node_modules/', + '/.data/', + ], static::internalPaths()); + } + + protected static function internalPaths(): array { + return [ + '/LICENSE', + '/CODE_OF_CONDUCT.md', + '/CONTRIBUTING.md', + '/LICENSE', + '/SECURITY.md', + '/.vortex/docs', + '/.vortex/tests', + ]; + } + + protected static function isInternalPath($relative_path): bool { + $relative_path = '/' . ltrim((string) $relative_path, './'); + + return in_array($relative_path, static::internalPaths()); + } + + protected static function fileIsExcludedFromProcessing($filename): int|false { + $excluded_patterns = [ + '.+\.png', + '.+\.jpg', + '.+\.jpeg', + '.+\.bpm', + '.+\.tiff', + ]; + + return preg_match('/^(' . implode('|', $excluded_patterns) . ')$/', (string) $filename); + } + + /** + * Execute command wrapper. + */ + protected function doExec($command, array &$output = NULL, &$return_var = NULL): string|false { + if ($this->isInstallDebug()) { + $this->status(sprintf('COMMAND: %s', $command), self::INSTALLER_STATUS_DEBUG); + } + $result = exec($command, $output, $return_var); + if ($this->isInstallDebug()) { + $this->status(sprintf(' OUTPUT: %s', implode('', $output)), self::INSTALLER_STATUS_DEBUG); + $this->status(sprintf(' CODE : %s', $return_var), self::INSTALLER_STATUS_DEBUG); + $this->status(sprintf(' RESULT: %s', $result), self::INSTALLER_STATUS_DEBUG); + } + + return $result; + } + + protected static function rmdirRecursive($directory, array $options = []) { + if (!isset($options['traverseSymlinks'])) { + $options['traverseSymlinks'] = FALSE; + } + $items = glob($directory . DIRECTORY_SEPARATOR . '{,.}*', GLOB_MARK | GLOB_BRACE); + foreach ($items as $item) { + if (basename($item) === '.' || basename($item) === '..') { + continue; + } + if (substr($item, -1) === DIRECTORY_SEPARATOR) { + if (!$options['traverseSymlinks'] && is_link(rtrim($item, DIRECTORY_SEPARATOR))) { + unlink(rtrim($item, DIRECTORY_SEPARATOR)); + } + else { + static::rmdirRecursive($item, $options); + } + } + else { + unlink($item); + } + } + if (is_dir($directory = rtrim((string) $directory, '\\/'))) { + if (is_link($directory)) { + unlink($directory); + } + else { + rmdir($directory); + } + } + } + + protected static function rmdirRecursiveEmpty($directory, $options = []) { + if (static::dirIsEmpty($directory)) { + static::rmdirRecursive($directory, $options); + static::rmdirRecursiveEmpty(dirname((string) $directory), $options); + } + } + + protected static function dirIsEmpty($directory): bool { + return is_dir($directory) && count(scandir($directory)) === 2; + } + + protected function status(string $message, $level = self::INSTALLER_STATUS_MESSAGE, $eol = TRUE, $use_prefix = TRUE) { + $prefix = ''; + $color = NULL; + + switch ($level) { + case self::INSTALLER_STATUS_SUCCESS: + $prefix = '✓️'; + $color = 'success'; + break; + + case self::INSTALLER_STATUS_ERROR: + $prefix = '✗'; + $color = 'error'; + break; + + case self::INSTALLER_STATUS_MESSAGE: + $prefix = 'i️'; + $color = 'info'; + break; + + case self::INSTALLER_STATUS_DEBUG: + $prefix = ' [D]'; + break; + } + + if ($level != self::INSTALLER_STATUS_DEBUG || $this->isInstallDebug()) { + $this->out(($use_prefix ? $prefix . ' ' : '') . $message, $color, $eol); + } + } + + protected static function parseDotenv($filename = '.env'): false|array { + if (!is_readable($filename)) { + return FALSE; + } + + $contents = file_get_contents($filename); + // Replace all # not inside quotes. + $contents = preg_replace('/#(?=(?:(?:[^"]*"){2})*[^"]*$)/', ';', $contents); + + return parse_ini_string((string) $contents); + } + + protected static function loadDotenv($filename = '.env', $override_existing = FALSE) { + $parsed = static::parseDotenv($filename); + + if ($parsed === FALSE) { + return; + } + + foreach ($parsed as $var => $value) { + if (!static::getenvOrDefault($var) || $override_existing) { + putenv($var . '=' . $value); + } + } + + $GLOBALS['_ENV'] = $GLOBALS['_ENV'] ?? []; + $GLOBALS['_SERVER'] = $GLOBALS['_SERVER'] ?? []; + + if ($override_existing) { + $GLOBALS['_ENV'] = $parsed + $GLOBALS['_ENV']; + $GLOBALS['_SERVER'] = $parsed + $GLOBALS['_SERVER']; + } + else { + $GLOBALS['_ENV'] += $parsed; + $GLOBALS['_SERVER'] += $parsed; + } + } + + /** + * Reliable wrapper to work with environment values. + */ + protected static function getenvOrDefault($name, $default = NULL) { + $vars = getenv(); + + if (!isset($vars[$name]) || $vars[$name] === '') { + return $default; + } + + return $vars[$name]; + } + + public static function tempdir($dir = NULL, $prefix = 'tmp_', $mode = 0700, $max_attempts = 1000): false|string { + if (is_null($dir)) { + $dir = sys_get_temp_dir(); + } + + $dir = rtrim((string) $dir, DIRECTORY_SEPARATOR); + + if (!is_dir($dir) || !is_writable($dir)) { + return FALSE; + } + + if (strpbrk((string) $prefix, '\\/:*?"<>|') !== FALSE) { + return FALSE; + } + $attempts = 0; + + do { + $path = sprintf('%s%s%s%s', $dir, DIRECTORY_SEPARATOR, $prefix, mt_rand(100000, mt_getrandmax())); + } while (!mkdir($path, $mode) && $attempts++ < $max_attempts); + + if (!is_dir($path) || !is_writable($path)) { + throw new \RuntimeException(sprintf('Unable to create temporary directory "%s".', $path)); + } + + return $path; + } + + protected function commandExists(string $command) { + $this->doExec('command -v ' . $command, $lines, $ret); + if ($ret === 1) { + throw new \RuntimeException(sprintf('Command "%s" does not exist in the current environment.', $command)); + } + } + + protected static function toHumanName($value): ?string { + $value = preg_replace('/[^a-zA-Z0-9]/', ' ', (string) $value); + $value = trim((string) $value); + + return preg_replace('/\s{2,}/', ' ', $value); + } + + protected static function toMachineName($value, $preserve_chars = []): string { + $preserve = ''; + foreach ($preserve_chars as $char) { + $preserve .= preg_quote((string) $char, '/'); + } + $pattern = '/[^a-zA-Z0-9' . $preserve . ']/'; + + $value = preg_replace($pattern, '_', (string) $value); + + return strtolower((string) $value); + } + + protected static function toCamelCase($value, $capitalise_first = FALSE): string|array { + $value = str_replace(' ', '', ucwords((string) preg_replace('/[^a-zA-Z0-9]/', ' ', (string) $value))); + + return $capitalise_first ? $value : lcfirst($value); + } + + protected function toAbbreviation($value, $length = 2, $word_delim = '_'): string|array { + $value = trim((string) $value); + $value = str_replace(' ', '_', $value); + $parts = explode($word_delim, $value); + if (count($parts) == 1) { + return strlen($parts[0]) > $length ? substr($parts[0], 0, $length) : $value; + } + + $value = implode('', array_map(static function ($word): string { + return substr($word, 0, 1); + }, $parts)); + + return substr($value, 0, $length); + } + + protected function executeCallback(string $prefix, $name) { + $args = func_get_args(); + $args = array_slice($args, 2); + + $name = $this->snakeToPascal($name); + + $callback = [static::class, $prefix . $name]; + if (method_exists($callback[0], $callback[1])) { + return call_user_func_array($callback, $args); + } + + return NULL; + } + + protected function snakeToPascal($string): string { + return str_replace(' ', '', ucwords(str_replace('_', ' ', (string) $string))); + } + + protected function getComposerJsonValue($name) { + $composer_json = $this->getDstDir() . DIRECTORY_SEPARATOR . 'composer.json'; + if (is_readable($composer_json)) { + $json = json_decode(file_get_contents($composer_json), TRUE); + if (isset($json[$name])) { + return $json[$name]; + } + } + + return NULL; + } + + protected function getStdinHandle() { + global $_stdin_handle; + if (!$_stdin_handle) { + $h = fopen('php://stdin', 'r'); + $_stdin_handle = stream_isatty($h) || static::getenvOrDefault('VORTEX_INSTALLER_FORCE_TTY') ? $h : fopen('/dev/tty', 'r+'); + } + + return $_stdin_handle; + } + + protected function closeStdinHandle() { + $_stdin_handle = $this->getStdinHandle(); + fclose($_stdin_handle); + } + + protected function out($text, $color = NULL, $new_line = TRUE) { + $styles = [ + 'success' => "\033[0;32m%s\033[0m", + 'error' => "\033[31;31m%s\033[0m", + ]; + + $format = '%s'; + + if (isset($styles[$color]) && $this->getConfig('ANSI')) { + $format = $styles[$color]; + } + + if ($new_line) { + $format .= PHP_EOL; + } + + printf($format, $text); + } + + protected function debug($value, string $name = '') { + print PHP_EOL; + print trim($name . ' DEBUG START') . PHP_EOL; + print print_r($value, TRUE) . PHP_EOL; + print trim($name . ' DEBUG FINISH') . PHP_EOL; + print PHP_EOL; + } + +} diff --git a/.vortex/installer/src/app.php b/.vortex/installer/src/app.php new file mode 100644 index 000000000..8946edd06 --- /dev/null +++ b/.vortex/installer/src/app.php @@ -0,0 +1,20 @@ +add($command); +$application->setDefaultCommand($command->getName(), TRUE); + +$application->run(); diff --git a/.vortex/installer/tests/phpunit/Fixtures/copyfiles/dir/file_in_dir.txt b/.vortex/installer/tests/phpunit/Fixtures/copyfiles/dir/file_in_dir.txt new file mode 100644 index 000000000..e69de29bb diff --git a/.vortex/installer/tests/phpunit/Fixtures/copyfiles/dir/subdir/file_in_subdir.txt b/.vortex/installer/tests/phpunit/Fixtures/copyfiles/dir/subdir/file_in_subdir.txt new file mode 100644 index 000000000..e69de29bb diff --git a/.vortex/installer/tests/phpunit/Fixtures/copyfiles/dir/subdir/file_link_from_subdir.txt b/.vortex/installer/tests/phpunit/Fixtures/copyfiles/dir/subdir/file_link_from_subdir.txt new file mode 120000 index 000000000..caae8c798 --- /dev/null +++ b/.vortex/installer/tests/phpunit/Fixtures/copyfiles/dir/subdir/file_link_from_subdir.txt @@ -0,0 +1 @@ +../../file.txt \ No newline at end of file diff --git a/.vortex/installer/tests/phpunit/Fixtures/copyfiles/dir/subdir_link b/.vortex/installer/tests/phpunit/Fixtures/copyfiles/dir/subdir_link new file mode 120000 index 000000000..8bbe8a537 --- /dev/null +++ b/.vortex/installer/tests/phpunit/Fixtures/copyfiles/dir/subdir_link @@ -0,0 +1 @@ +subdir \ No newline at end of file diff --git a/.vortex/installer/tests/phpunit/Fixtures/copyfiles/dir_link b/.vortex/installer/tests/phpunit/Fixtures/copyfiles/dir_link new file mode 120000 index 000000000..872451932 --- /dev/null +++ b/.vortex/installer/tests/phpunit/Fixtures/copyfiles/dir_link @@ -0,0 +1 @@ +dir \ No newline at end of file diff --git a/.vortex/installer/tests/phpunit/Fixtures/copyfiles/file.txt b/.vortex/installer/tests/phpunit/Fixtures/copyfiles/file.txt new file mode 100755 index 000000000..d670460b4 --- /dev/null +++ b/.vortex/installer/tests/phpunit/Fixtures/copyfiles/file.txt @@ -0,0 +1 @@ +test content diff --git a/.vortex/installer/tests/phpunit/Fixtures/copyfiles/file_link.txt b/.vortex/installer/tests/phpunit/Fixtures/copyfiles/file_link.txt new file mode 120000 index 000000000..4c330738c --- /dev/null +++ b/.vortex/installer/tests/phpunit/Fixtures/copyfiles/file_link.txt @@ -0,0 +1 @@ +file.txt \ No newline at end of file diff --git a/.vortex/installer/tests/phpunit/Fixtures/copyfiles/subdir_link_root b/.vortex/installer/tests/phpunit/Fixtures/copyfiles/subdir_link_root new file mode 120000 index 000000000..06b51ee8a --- /dev/null +++ b/.vortex/installer/tests/phpunit/Fixtures/copyfiles/subdir_link_root @@ -0,0 +1 @@ +dir/subdir \ No newline at end of file diff --git a/.vortex/installer/tests/phpunit/Fixtures/tokens/dir1/foobar_b.txt b/.vortex/installer/tests/phpunit/Fixtures/tokens/dir1/foobar_b.txt new file mode 100644 index 000000000..e589f2939 --- /dev/null +++ b/.vortex/installer/tests/phpunit/Fixtures/tokens/dir1/foobar_b.txt @@ -0,0 +1,5 @@ +#;< FOO +token content line 1 +token content line 2 +#;> BAR +last line diff --git a/.vortex/installer/tests/phpunit/Fixtures/tokens/dir1/foofoo_b.txt b/.vortex/installer/tests/phpunit/Fixtures/tokens/dir1/foofoo_b.txt new file mode 100644 index 000000000..e5cfa985c --- /dev/null +++ b/.vortex/installer/tests/phpunit/Fixtures/tokens/dir1/foofoo_b.txt @@ -0,0 +1,5 @@ +#;< FOO +token content line 1 +token content line 2 +#;> FOO +last line diff --git a/.vortex/installer/tests/phpunit/Fixtures/tokens/empty.txt b/.vortex/installer/tests/phpunit/Fixtures/tokens/empty.txt new file mode 100644 index 000000000..e69de29bb diff --git a/.vortex/installer/tests/phpunit/Fixtures/tokens/foo/foobar_b.txt b/.vortex/installer/tests/phpunit/Fixtures/tokens/foo/foobar_b.txt new file mode 100644 index 000000000..e589f2939 --- /dev/null +++ b/.vortex/installer/tests/phpunit/Fixtures/tokens/foo/foobar_b.txt @@ -0,0 +1,5 @@ +#;< FOO +token content line 1 +token content line 2 +#;> BAR +last line diff --git a/.vortex/installer/tests/phpunit/Fixtures/tokens/foo/foofoo_b.txt b/.vortex/installer/tests/phpunit/Fixtures/tokens/foo/foofoo_b.txt new file mode 100644 index 000000000..e5cfa985c --- /dev/null +++ b/.vortex/installer/tests/phpunit/Fixtures/tokens/foo/foofoo_b.txt @@ -0,0 +1,5 @@ +#;< FOO +token content line 1 +token content line 2 +#;> FOO +last line diff --git a/.vortex/installer/tests/phpunit/Fixtures/tokens/foobar_b.txt b/.vortex/installer/tests/phpunit/Fixtures/tokens/foobar_b.txt new file mode 100644 index 000000000..e589f2939 --- /dev/null +++ b/.vortex/installer/tests/phpunit/Fixtures/tokens/foobar_b.txt @@ -0,0 +1,5 @@ +#;< FOO +token content line 1 +token content line 2 +#;> BAR +last line diff --git a/.vortex/installer/tests/phpunit/Fixtures/tokens/foobar_e.txt b/.vortex/installer/tests/phpunit/Fixtures/tokens/foobar_e.txt new file mode 100644 index 000000000..159e4fe17 --- /dev/null +++ b/.vortex/installer/tests/phpunit/Fixtures/tokens/foobar_e.txt @@ -0,0 +1,5 @@ +first line +#;< FOO +token content line 1 +token content line 2 +#;> BAR diff --git a/.vortex/installer/tests/phpunit/Fixtures/tokens/foobar_m.txt b/.vortex/installer/tests/phpunit/Fixtures/tokens/foobar_m.txt new file mode 100644 index 000000000..8d7f30faf --- /dev/null +++ b/.vortex/installer/tests/phpunit/Fixtures/tokens/foobar_m.txt @@ -0,0 +1,6 @@ +first line +#;< FOO +token content line 1 +token content line 2 +#;> BAR +last line diff --git a/.vortex/installer/tests/phpunit/Fixtures/tokens/foofoo_b.txt b/.vortex/installer/tests/phpunit/Fixtures/tokens/foofoo_b.txt new file mode 100644 index 000000000..e5cfa985c --- /dev/null +++ b/.vortex/installer/tests/phpunit/Fixtures/tokens/foofoo_b.txt @@ -0,0 +1,5 @@ +#;< FOO +token content line 1 +token content line 2 +#;> FOO +last line diff --git a/.vortex/installer/tests/phpunit/Fixtures/tokens/foofoo_e.txt b/.vortex/installer/tests/phpunit/Fixtures/tokens/foofoo_e.txt new file mode 100644 index 000000000..5cbe9a077 --- /dev/null +++ b/.vortex/installer/tests/phpunit/Fixtures/tokens/foofoo_e.txt @@ -0,0 +1,5 @@ +first line +#;< FOO +token content line 1 +token content line 2 +#;> FOO diff --git a/.vortex/installer/tests/phpunit/Fixtures/tokens/foofoo_m.txt b/.vortex/installer/tests/phpunit/Fixtures/tokens/foofoo_m.txt new file mode 100644 index 000000000..2f8af8613 --- /dev/null +++ b/.vortex/installer/tests/phpunit/Fixtures/tokens/foofoo_m.txt @@ -0,0 +1,6 @@ +first line +#;< FOO +token content line 1 +token content line 2 +#;> FOO +last line diff --git a/.vortex/installer/tests/phpunit/Fixtures/tokens/lines_1.txt b/.vortex/installer/tests/phpunit/Fixtures/tokens/lines_1.txt new file mode 100644 index 000000000..08fe2720d --- /dev/null +++ b/.vortex/installer/tests/phpunit/Fixtures/tokens/lines_1.txt @@ -0,0 +1 @@ +first line diff --git a/.vortex/installer/tests/phpunit/Fixtures/tokens/lines_123.txt b/.vortex/installer/tests/phpunit/Fixtures/tokens/lines_123.txt new file mode 100644 index 000000000..7c377168c --- /dev/null +++ b/.vortex/installer/tests/phpunit/Fixtures/tokens/lines_123.txt @@ -0,0 +1,3 @@ +first line +token content line 1 +token content line 2 diff --git a/.vortex/installer/tests/phpunit/Fixtures/tokens/lines_1234.txt b/.vortex/installer/tests/phpunit/Fixtures/tokens/lines_1234.txt new file mode 100644 index 000000000..3f8b28347 --- /dev/null +++ b/.vortex/installer/tests/phpunit/Fixtures/tokens/lines_1234.txt @@ -0,0 +1,4 @@ +first line +token content line 1 +token content line 2 +last line diff --git a/.vortex/installer/tests/phpunit/Fixtures/tokens/lines_14.txt b/.vortex/installer/tests/phpunit/Fixtures/tokens/lines_14.txt new file mode 100644 index 000000000..5776cea46 --- /dev/null +++ b/.vortex/installer/tests/phpunit/Fixtures/tokens/lines_14.txt @@ -0,0 +1,2 @@ +first line +last line diff --git a/.vortex/installer/tests/phpunit/Fixtures/tokens/lines_234.txt b/.vortex/installer/tests/phpunit/Fixtures/tokens/lines_234.txt new file mode 100644 index 000000000..e7029e2a3 --- /dev/null +++ b/.vortex/installer/tests/phpunit/Fixtures/tokens/lines_234.txt @@ -0,0 +1,3 @@ +token content line 1 +token content line 2 +last line diff --git a/.vortex/installer/tests/phpunit/Fixtures/tokens/lines_4.txt b/.vortex/installer/tests/phpunit/Fixtures/tokens/lines_4.txt new file mode 100644 index 000000000..776d2d031 --- /dev/null +++ b/.vortex/installer/tests/phpunit/Fixtures/tokens/lines_4.txt @@ -0,0 +1 @@ +last line diff --git a/.vortex/installer/tests/phpunit/Traits/ReflectionTrait.php b/.vortex/installer/tests/phpunit/Traits/ReflectionTrait.php new file mode 100644 index 000000000..f74b451a9 --- /dev/null +++ b/.vortex/installer/tests/phpunit/Traits/ReflectionTrait.php @@ -0,0 +1,100 @@ +hasMethod($name)) { + throw new \InvalidArgumentException(sprintf('Method %s does not exist', $name)); + } + + $method = $class->getMethod($name); + + $original_accessibility = $method->isPublic(); + + // Set method accessibility to true, so it can be invoked. + $method->setAccessible(TRUE); + + // If the method is static, we won't pass an object instance to invokeArgs() + // Otherwise, we ensure to pass the object instance. + $invoke_object = $method->isStatic() ? NULL : (is_object($object) ? $object : NULL); + + // Ensure we have an object for non-static methods. + if (!$method->isStatic() && $invoke_object === NULL) { + throw new \InvalidArgumentException("An object instance is required for non-static methods"); + } + + $result = $method->invokeArgs($invoke_object, $args); + + // Reset the method's accessibility to its original state. + $method->setAccessible($original_accessibility); + + return $result; + } + + /** + * Set protected property value. + * + * @param object $object + * Object to set the value on. + * @param string $property + * Property name to set the value. Property should exists in the object. + * @param mixed $value + * Value to set to the property. + */ + protected static function setProtectedValue($object, $property, mixed $value): void { + $class = new \ReflectionClass($object::class); + $property = $class->getProperty($property); + $property->setAccessible(TRUE); + + $property->setValue($object, $value); + } + + /** + * Get protected value from the object. + * + * @param object $object + * Object to set the value on. + * @param string $property + * Property name to get the value. Property should exists in the object. + * + * @return mixed + * Protected property value. + */ + protected static function getProtectedValue($object, $property) { + $class = new \ReflectionClass($object::class); + $property = $class->getProperty($property); + $property->setAccessible(TRUE); + + return $property->getValue($class); + } + +} diff --git a/.vortex/installer/tests/phpunit/Unit/Command/InstallCommandTest.php b/.vortex/installer/tests/phpunit/Unit/Command/InstallCommandTest.php new file mode 100644 index 000000000..b4746a0cc --- /dev/null +++ b/.vortex/installer/tests/phpunit/Unit/Command/InstallCommandTest.php @@ -0,0 +1,43 @@ +add(new InstallCommand()); + + $command = $application->find('Vortex CLI installer'); + $command_tester = new CommandTester($command); + + $command_tester->execute(['--help' => NULL], [ + 'interactive' => FALSE, + 'verbosity' => OutputInterface::VERBOSITY_VERBOSE, + 'capture_stderr_separately' => FALSE, + ]); + + // The output of the command in the console. + $output = $command_tester->getDisplay(); + $this->assertStringContainsString('php install destination', $output); + } + +} diff --git a/.vortex/installer/tests/phpunit/Unit/CopyRecursiveTest.php b/.vortex/installer/tests/phpunit/Unit/CopyRecursiveTest.php new file mode 100644 index 000000000..578f3b440 --- /dev/null +++ b/.vortex/installer/tests/phpunit/Unit/CopyRecursiveTest.php @@ -0,0 +1,64 @@ +prepareFixtureDir(); + } + + protected function tearDown(): void { + parent::tearDown(); + $this->cleanupFixtureDir(); + } + + /** + * @covers ::copyRecursive + */ + public function testCopyRecursive(): void { + $files_dir = $this->getFixtureDir('copyfiles'); + + $this->callProtectedMethod(InstallCommand::class, 'copyRecursive', [$files_dir, $this->fixtureDir]); + + $dir = $this->fixtureDir . DIRECTORY_SEPARATOR; + + $this->assertTrue(is_file($dir . 'file.txt')); + $this->assertTrue((fileperms($dir . 'file.txt') & 0777) === 0755); + $this->assertTrue(is_dir($dir . 'dir')); + $this->assertTrue(is_file($dir . 'dir/file_in_dir.txt')); + $this->assertTrue(is_dir($dir . 'dir/subdir')); + $this->assertTrue(is_file($dir . 'dir/subdir/file_in_subdir.txt')); + + $this->assertTrue(is_link($dir . 'file_link.txt')); + + $this->assertTrue(is_link($dir . 'dir_link')); + $this->assertTrue(is_dir($dir . 'dir_link/subdir')); + $this->assertTrue(is_file($dir . 'dir_link/subdir/file_in_subdir.txt')); + $this->assertTrue(is_link($dir . 'dir_link/subdir/file_link_from_subdir.txt')); + + $this->assertTrue(is_link($dir . 'subdir_link_root')); + $this->assertTrue(is_link($dir . 'subdir_link_root/file_link_from_subdir.txt')); + $this->assertTrue((fileperms($dir . 'subdir_link_root/file_link_from_subdir.txt') & 0777) === 0755); + $this->assertTrue(is_file($dir . 'subdir_link_root/file_in_subdir.txt')); + + $this->assertTrue(is_link($dir . 'dir/subdir_link')); + $this->assertTrue(is_dir($dir . 'dir/subdir_link')); + + $this->assertDirectoryDoesNotExist($dir . 'emptydir'); + } + +} diff --git a/.vortex/installer/tests/phpunit/Unit/DotEnvTest.php b/.vortex/installer/tests/phpunit/Unit/DotEnvTest.php new file mode 100644 index 000000000..7b4235ff0 --- /dev/null +++ b/.vortex/installer/tests/phpunit/Unit/DotEnvTest.php @@ -0,0 +1,226 @@ +backupEnv = $GLOBALS['_ENV']; + $this->backupServer = $GLOBALS['_SERVER']; + + parent::setUp(); + } + + protected function tearDown(): void { + $GLOBALS['_ENV'] = $this->backupEnv; + $GLOBALS['_SERVER'] = $this->backupServer; + } + + /** + * @covers ::loadDotenv + */ + public function testGetEnv(): void { + $content = 'var1=val1'; + $filename = $this->createFixtureEnvFile($content); + + $this->assertEmpty(getenv('var1'), getenv('var1')); + $this->callProtectedMethod(InstallCommand::class, 'loadDotenv', [$filename]); + $this->assertEquals('val1', getenv('var1')); + + // Try overloading with the same value - should not allow. + $content = 'var1=val11'; + $filename = $this->createFixtureEnvFile($content); + $this->callProtectedMethod(InstallCommand::class, 'loadDotenv', [$filename]); + $this->assertEquals('val1', getenv('var1')); + + // Force overriding of existing variables. + $content = 'var1=val11'; + $filename = $this->createFixtureEnvFile($content); + $this->callProtectedMethod(InstallCommand::class, 'loadDotenv', [$filename]); + // @todo Fix this test. + // $this->assertEquals('val11', getenv('var1')); + } + + /** + * @dataProvider dataProviderGlobals + * @covers ::loadDotenv + */ + public function testGlobals(string $content, array $env_before, array $server_before, array $env_after, mixed $server_after, bool $allow_override): void { + $filename = $this->createFixtureEnvFile($content); + + $GLOBALS['_ENV'] = $env_before; + $GLOBALS['_SERVER'] = $server_before; + + $this->callProtectedMethod(InstallCommand::class, 'loadDotenv', [$filename]); + + // @todo Fix this test. + // $this->assertEquals($GLOBALS['_ENV'], $env_after); + $this->assertEquals($GLOBALS['_SERVER'], $server_after); + + $this->assertTrue(TRUE); + } + + public static function dataProviderGlobals(): array { + return [ + [ + '', [], [], [], [], FALSE, + ], + [ + '', ['var1' => 'val1'], ['var2' => 'val2'], ['var1' => 'val1'], ['var2' => 'val2'], FALSE, + ], + // Simple value. + [ + 'var3=val3', + ['var1' => 'val1'], + ['var2' => 'val2'], + ['var1' => 'val1', 'var3' => 'val3'], + ['var2' => 'val2', 'var3' => 'val3'], + FALSE, + ], + // Multiple values. + [ + ' + var3=val3 + var4=val4 + ', + ['var1' => 'val1'], + ['var2' => 'val2'], + ['var1' => 'val1', 'var3' => 'val3', 'var4' => 'val4'], + ['var2' => 'val2', 'var3' => 'val3', 'var4' => 'val4'], + FALSE, + ], + // Empty value. + [ + 'var3=', + ['var1' => 'val1'], + ['var2' => 'val2'], + ['var1' => 'val1', 'var3' => ''], + ['var2' => 'val2', 'var3' => ''], + FALSE, + ], + [ + 'var3=""', + ['var1' => 'val1'], + ['var2' => 'val2'], + ['var1' => 'val1', 'var3' => ''], + ['var2' => 'val2', 'var3' => ''], + FALSE, + ], + // Preserve existing values. + [ + ' + var1=val11 + var4=val4 + ', + ['var1' => 'val1'], + ['var2' => 'val2'], + ['var1' => 'val1', 'var4' => 'val4'], + ['var2' => 'val2', 'var1' => 'val11', 'var4' => 'val4'], + FALSE, + ], + // Override existing values. + [ + ' + var1=val11 + var4=val4 + ', + ['var1' => 'val1'], + ['var2' => 'val2'], + ['var1' => 'val11', 'var4' => 'val4'], + ['var2' => 'val2', 'var1' => 'val11', 'var4' => 'val4'], + TRUE, + ], + // Comments. + [ + ' + var3=val3 + # var4=val4 + ', + ['var1' => 'val1'], + ['var2' => 'val2'], + ['var1' => 'val1', 'var3' => 'val3'], + ['var2' => 'val2', 'var3' => 'val3'], + FALSE, + ], + [ + ' + var3=val3 + var4=val4 # inline comment + ', + ['var1' => 'val1'], + ['var2' => 'val2'], + ['var1' => 'val1', 'var3' => 'val3', 'var4' => 'val4'], + ['var2' => 'val2', 'var3' => 'val3', 'var4' => 'val4'], + FALSE, + ], + [ + ' + var3=val3 + var4="val4 # inside" + ', + ['var1' => 'val1'], + ['var2' => 'val2'], + ['var1' => 'val1', 'var3' => 'val3', 'var4' => 'val4 # inside'], + ['var2' => 'val2', 'var3' => 'val3', 'var4' => 'val4 # inside'], + FALSE, + ], + [ + ' + var3=val3 + #var4="val4 # inside" + ', + ['var1' => 'val1'], + ['var2' => 'val2'], + ['var1' => 'val1', 'var3' => 'val3'], + ['var2' => 'val2', 'var3' => 'val3'], + FALSE, + ], + [ + ' + var3=val3 + var4="val4 # inside" # inline comment after code + ', + ['var1' => 'val1'], + ['var2' => 'val2'], + ['var1' => 'val1', 'var3' => 'val3', 'var4' => 'val4 # inside'], + ['var2' => 'val2', 'var3' => 'val3', 'var4' => 'val4 # inside'], + FALSE, + ], + ]; + } + + protected function createFixtureEnvFile($content): string|false { + $filename = tempnam(sys_get_temp_dir(), '.env'); + file_put_contents($filename, $content); + + return $filename; + } + +} diff --git a/.vortex/installer/tests/phpunit/Unit/HelpersTest.php b/.vortex/installer/tests/phpunit/Unit/HelpersTest.php new file mode 100644 index 000000000..4f872f708 --- /dev/null +++ b/.vortex/installer/tests/phpunit/Unit/HelpersTest.php @@ -0,0 +1,159 @@ +callProtectedMethod(InstallCommand::class, 'toHumanName', [$value]); + $this->assertEquals($expected, $actual); + } + + public static function dataProviderToHumanName(): array { + return [ + ['', ''], + [' ', ''], + [' word ', 'word'], + ['word other', 'word other'], + ['word other', 'word other'], + ['word other', 'word other'], + ['word-other', 'word other'], + ['word_other', 'word other'], + ['word_-other', 'word other'], + ['word_ - other', 'word other'], + [' _word_ - other - ', 'word other'], + [' _word_ - other - third', 'word other third'], + [' _%word_$ -# other -@ third!,', 'word other third'], + ]; + } + + /** + * @dataProvider dataProviderToMachineName + * @covers ::toMachineName + */ + public function testToMachineName(string $value, array $preserve, mixed $expected): void { + $actual = $this->callProtectedMethod(InstallCommand::class, 'toMachineName', [$value, $preserve]); + $this->assertEquals($expected, $actual); + } + + public static function dataProviderToMachineName(): array { + return [ + ['', [], ''], + [' ', [], '_'], + [' word ', [], '_word_'], + ['word other', [], 'word_other'], + ['word other', [], 'word__other'], + ['word other', [], 'word___other'], + ['word-other', [], 'word_other'], + ['word_other', [], 'word_other'], + ['word_-other', [], 'word__other'], + ['word_ - other', [], 'word____other'], + [' _word_ - other - ', [], '__word____other___'], + [' _word_ - other - third', [], '__word____other___third'], + [' _%word_$ -# Other -@ Third!,', [], '___word______other____third__'], + + ['', ['-'], ''], + [' ', ['-'], '_'], + [' word ', ['-'], '_word_'], + ['word other', ['-'], 'word_other'], + ['word other', ['-'], 'word__other'], + ['word other', ['-'], 'word___other'], + ['word-other', ['-'], 'word-other'], + ['word_other', ['-'], 'word_other'], + ['word_-other', ['-'], 'word_-other'], + ['word_ - other', ['-'], 'word__-_other'], + [' _word_ - other - ', ['-'], '__word__-_other_-_'], + [' _word_ - other - third', ['-'], '__word__-_other_-_third'], + [' _%word_$ -# Other -@ Third!,', ['-'], '___word___-__other_-__third__'], + ]; + } + + /** + * @dataProvider dataProviderToCamelCase + * @covers ::toCamelCase + */ + public function testToCamelCase(string $value, bool $capitalise_first, mixed $expected): void { + $actual = $this->callProtectedMethod(InstallCommand::class, 'toCamelCase', [$value, $capitalise_first]); + $this->assertEquals($expected, $actual); + } + + public static function dataProviderToCamelCase(): array { + return [ + ['', FALSE, ''], + [' ', FALSE, ''], + [' word ', FALSE, 'word'], + [' word ', TRUE, 'Word'], + ['word other', FALSE, 'wordOther'], + ['word other', TRUE, 'WordOther'], + ['word other', FALSE, 'wordOther'], + ['word other', TRUE, 'WordOther'], + ['word- other', FALSE, 'wordOther'], + ['word- other', TRUE, 'WordOther'], + ['%word- * other', FALSE, 'wordOther'], + ['%word- * other', TRUE, 'WordOther'], + [' _%word_$ -# Other -@ Third!,', FALSE, 'wordOtherThird'], + [' _%word_$ -# Other -@ Third!,', TRUE, 'WordOtherThird'], + ]; + } + + /** + * @dataProvider dataProviderIsRegex + * @covers ::isRegex + */ + public function testIsRegex(string $value, mixed $expected): void { + $actual = $this->callProtectedMethod(InstallCommand::class, 'isRegex', [$value]); + $this->assertEquals($expected, $actual); + } + + public static function dataProviderIsRegex(): array { + return [ + ['', FALSE], + + // Valid regular expressions. + ["/^[a-z]$/", TRUE], + ["#[a-z]*#i", TRUE], + ["{\\d+}", TRUE], + ["(\\d+)", TRUE], + ["<[A-Z]{3,6}>", TRUE], + + // Invalid regular expressions (wrong delimiters or syntax). + ["^[a-z]$", FALSE], + ["/[a-z", FALSE], + ["[a-z]+/", FALSE], + ["{[a-z]*", FALSE], + ["(a-z]", FALSE], + + // Edge cases. + // Valid, but '*' as delimiter would be invalid. + ["/a*/", TRUE], + // Empty string. + ["", FALSE], + // Just delimiters, no pattern. + ["//", FALSE], + + ['web/', FALSE], + ['web\/', FALSE], + [': web', FALSE], + ['=web', FALSE], + ['!web', FALSE], + ['/web', FALSE], + ]; + } + +} diff --git a/.vortex/installer/tests/phpunit/Unit/TokenTest.php b/.vortex/installer/tests/phpunit/Unit/TokenTest.php new file mode 100644 index 000000000..17315b811 --- /dev/null +++ b/.vortex/installer/tests/phpunit/Unit/TokenTest.php @@ -0,0 +1,239 @@ +prepareFixtureDir(); + } + + protected function tearDown(): void { + parent::tearDown(); + $this->cleanupFixtureDir(); + } + + /** + * Flatten file tree. + */ + protected function flattenFileTree($tree, string $parent = '.'): array { + $flatten = []; + foreach ($tree as $dir => $file) { + if (is_array($file)) { + $flatten = array_merge($flatten, $this->flattenFileTree($file, $parent . DIRECTORY_SEPARATOR . $dir)); + } + else { + $flatten[] = $parent . DIRECTORY_SEPARATOR . $file; + } + } + + return $flatten; + } + + /** + * @dataProvider dataProviderFileContains + * @covers ::fileContains + */ + public function testFileContains(string $string, string $file, mixed $expected): void { + $tokens_dir = $this->getFixtureDir('tokens'); + $files = $this->flattenFileTree([$file], $tokens_dir); + $created_files = $this->createFixtureFiles($files, $tokens_dir); + $created_file = reset($created_files); + + $actual = InstallCommand::fileContains($string, $created_file); + + $this->assertEquals($expected, $actual); + } + + public static function dataProviderFileContains(): array { + return [ + ['FOO', 'empty.txt', FALSE], + ['BAR', 'foobar_b.txt', TRUE], + ['FOO', 'dir1/foobar_b.txt', TRUE], + ['BAR', 'dir1/foobar_b.txt', TRUE], + // Regex. + ['/BA/', 'dir1/foobar_b.txt', TRUE], + ['/BAW/', 'dir1/foobar_b.txt', FALSE], + ['/BA.*/', 'dir1/foobar_b.txt', TRUE], + ]; + } + + /** + * @dataProvider dataProviderDirContains + * @covers ::dirContains + */ + public function testDirContains(string $string, array $files, mixed $expected): void { + $tokens_dir = $this->getFixtureDir('tokens'); + $files = $this->flattenFileTree($files, $tokens_dir); + $this->createFixtureFiles($files, $tokens_dir); + + $actual = $this->callProtectedMethod(InstallCommand::class, 'dirContains', [$string, $this->fixtureDir]); + + $this->assertEquals($expected, $actual); + } + + public static function dataProviderDirContains(): array { + return [ + ['FOO', ['empty.txt'], FALSE], + ['BAR', ['foobar_b.txt'], TRUE], + ['FOO', ['dir1/foobar_b.txt'], TRUE], + ['BAR', ['dir1/foobar_b.txt'], TRUE], + + // Regex. + ['/BA/', ['dir1/foobar_b.txt'], TRUE], + ['/BAW/', ['dir1/foobar_b.txt'], FALSE], + ['/BA.*/', ['dir1/foobar_b.txt'], TRUE], + ]; + } + + /** + * @dataProvider dataProviderRemoveTokenFromFile + * @covers ::removeTokenFromFile + */ + public function testRemoveTokenFromFile(string $file, string $begin, string $end, bool $with_content, bool $expect_exception, string $expected_file): void { + $tokens_dir = $this->getFixtureDir('tokens'); + $files = $this->flattenFileTree([$file], $tokens_dir); + $created_files = $this->createFixtureFiles($files, $tokens_dir); + $created_file = reset($created_files); + $expected_files = $this->flattenFileTree([$expected_file], $tokens_dir); + $expected_file = reset($expected_files); + + if ($expect_exception) { + $this->expectException(\RuntimeException::class); + } + + InstallCommand::removeTokenFromFile($created_file, $begin, $end, $with_content); + + $this->assertFileEquals($expected_file, $created_file); + } + + public static function dataProviderRemoveTokenFromFile(): array { + return [ + ['empty.txt', 'FOO', 'FOO', TRUE, FALSE, 'empty.txt'], + + // Different begin and end tokens. + ['foobar_b.txt', '#;< FOO', '#;> BAR', TRUE, FALSE, 'lines_4.txt'], + ['foobar_b.txt', '#;< FOO', '#;> BAR', FALSE, FALSE, 'lines_234.txt'], + + ['foobar_m.txt', '#;< FOO', '#;> BAR', TRUE, FALSE, 'lines_14.txt'], + ['foobar_m.txt', '#;< FOO', '#;> BAR', FALSE, FALSE, 'lines_1234.txt'], + + ['foobar_e.txt', '#;< FOO', '#;> BAR', TRUE, FALSE, 'lines_1.txt'], + ['foobar_e.txt', '#;< FOO', '#;> BAR', FALSE, FALSE, 'lines_123.txt'], + + // Same begin and end tokens. + ['foofoo_b.txt', '#;< FOO', '#;> FOO', TRUE, FALSE, 'lines_4.txt'], + ['foofoo_b.txt', '#;< FOO', '#;> FOO', FALSE, FALSE, 'lines_234.txt'], + + ['foofoo_m.txt', '#;< FOO', '#;> FOO', TRUE, FALSE, 'lines_14.txt'], + ['foofoo_m.txt', '#;< FOO', '#;> FOO', FALSE, FALSE, 'lines_1234.txt'], + + ['foofoo_e.txt', '#;< FOO', '#;> FOO', TRUE, FALSE, 'lines_1.txt'], + ['foofoo_e.txt', '#;< FOO', '#;> FOO', FALSE, FALSE, 'lines_123.txt'], + + // Tokens without ending trigger exception. + ['foobar_b.txt', '#;< FOO', '#;> FOO', TRUE, TRUE, 'lines_4.txt'], + ['foobar_b.txt', '#;< FOO', '#;> FOO', FALSE, TRUE, 'lines_234.txt'], + + ['foobar_m.txt', '#;< FOO', '#;> FOO', TRUE, TRUE, 'lines_14.txt'], + ['foobar_m.txt', '#;< FOO', '#;> FOO', FALSE, TRUE, 'lines_1234.txt'], + + ['foobar_e.txt', '#;< FOO', '#;> FOO', TRUE, TRUE, 'lines_1.txt'], + ['foobar_e.txt', '#;< FOO', '#;> FOO', FALSE, TRUE, 'lines_123.txt'], + ]; + } + + /** + * @dataProvider dataProviderDirReplaceContent + * @covers ::dirReplaceContent + */ + public function testDirReplaceContent(array $files, array $expected_files): void { + $tokens_dir = $this->getFixtureDir('tokens'); + $files = $this->flattenFileTree($files, $tokens_dir); + $expected_files = $this->flattenFileTree($expected_files, $tokens_dir); + $created_files = $this->createFixtureFiles($files, $tokens_dir); + + if (count($created_files) !== count($expected_files)) { + throw new \RuntimeException('Provided files number is not equal to expected files number.'); + } + + $this->callProtectedMethod(InstallCommand::class, 'dirReplaceContent', ['BAR', 'FOO', $this->fixtureDir]); + + foreach (array_keys($created_files) as $k) { + $this->assertFileEquals($expected_files[$k], $created_files[$k]); + } + } + + public static function dataProviderDirReplaceContent(): array { + return [ + [ + ['empty.txt'], + ['empty.txt'], + ], + [ + ['foobar_b.txt', 'foobar_m.txt', 'foobar_e.txt'], + ['foofoo_b.txt', 'foofoo_m.txt', 'foofoo_e.txt'], + ], + [ + ['dir1/foobar_b.txt'], + ['dir1/foofoo_b.txt'], + ], + ]; + } + + /** + * @dataProvider dataProviderReplaceStringFilename + * @covers ::replaceStringFilename + */ + public function testReplaceStringFilename(array $files, array $expected_files): void { + $tokens_dir = $this->getFixtureDir('tokens'); + $files = $this->flattenFileTree($files, $tokens_dir); + $expected_files = $this->flattenFileTree($expected_files, $this->fixtureDir); + $created_files = $this->createFixtureFiles($files, $tokens_dir, FALSE); + + if (count($created_files) !== count($expected_files)) { + throw new \RuntimeException('Provided files number is not equal to expected files number.'); + } + + $this->callProtectedMethod(InstallCommand::class, 'replaceStringFilename', ['foo', 'bar', $this->fixtureDir]); + + foreach (array_keys($expected_files) as $k) { + $this->assertFileExists($expected_files[$k]); + } + } + + public static function dataProviderReplaceStringFilename(): array { + return [ + [ + ['empty.txt'], + ['empty.txt'], + ], + [ + ['foofoo_b.txt'], + ['barbar_b.txt'], + ], + [ + ['dir1/foofoo_b.txt'], + ['dir1/barbar_b.txt'], + ], + [ + ['foo/foofoo_b.txt'], + ['bar/barbar_b.txt'], + ], + ]; + } + +} diff --git a/.vortex/installer/tests/phpunit/Unit/UnitTestBase.php b/.vortex/installer/tests/phpunit/Unit/UnitTestBase.php new file mode 100644 index 000000000..4ac64dcba --- /dev/null +++ b/.vortex/installer/tests/phpunit/Unit/UnitTestBase.php @@ -0,0 +1,87 @@ +fixtureDir = InstallCommand::tempdir(); + } + + /** + * Cleanup fixture directory. + */ + public function cleanupFixtureDir(): void { + $this->fileExists(); + $fs = new Filesystem(); + $fs->remove($this->fixtureDir); + } + + /** + * Create fixture files. + * + * @return string[] + * Created file names. + */ + protected function createFixtureFiles($files, $basedir = NULL, $append_rand = TRUE): array { + $fs = new Filesystem(); + $created = []; + + foreach ($files as $file) { + $basedir = $basedir ?? dirname((string) $file); + $relative_dst = ltrim(str_replace($basedir, '', (string) $file), '/') . ($append_rand ? rand(1000, 9999) : ''); + $new_name = $this->fixtureDir . DIRECTORY_SEPARATOR . $relative_dst; + $fs->copy($file, $new_name); + $created[] = $new_name; + } + + return $created; + } + + /** + * Get fixture directory. + * + * @param string|null $name + * Fixture directory name. + * + * @return string + * Fixture directory path. + */ + protected function getFixtureDir($name = NULL): string { + $parent = dirname(__FILE__); + $path = $parent . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'Fixtures'; + $path .= $name ? DIRECTORY_SEPARATOR . $name : ''; + if (!file_exists($path)) { + throw new \RuntimeException(sprintf('Unable to find fixture directory at path "%s".', $path)); + } + + return $path; + } + +} diff --git a/.vortex/tests/bats/_helper.bash b/.vortex/tests/bats/_helper.bash index 63e0630c8..e664b71fc 100644 --- a/.vortex/tests/bats/_helper.bash +++ b/.vortex/tests/bats/_helper.bash @@ -102,16 +102,11 @@ setup() { # Directory where the application may store it's temporary files. export APP_TMP_DIR="${BUILD_DIR}/tmp" - # Use unique installer checkout dir to allow multiple calls of this function - # with a single test. - export INSTALLER_CHECKOUT_DIR="${BUILD_DIR}/installer_$(random_string)" - fixture_prepare_dir "${BUILD_DIR}" fixture_prepare_dir "${CURRENT_PROJECT_DIR}" fixture_prepare_dir "${DST_PROJECT_DIR}" fixture_prepare_dir "${LOCAL_REPO_DIR}" fixture_prepare_dir "${APP_TMP_DIR}" - fixture_prepare_dir "${INSTALLER_CHECKOUT_DIR}" ## ## Phase 4: Application variables setup. @@ -957,8 +952,10 @@ run_installer_quiet() { opt_quiet="--quiet" [ "${TEST_RUN_INSTALL_INTERACTIVE:-}" = "1" ] && opt_quiet="" - download_installer "${INSTALLER_CHECKOUT_DIR}" - run php "${INSTALLER_CHECKOUT_DIR}/install.php" "${opt_quiet}" "$@" + [ ! -d "${ROOT_DIR}/.vortex/installer/vendor" ] && composer --working-dir="${ROOT_DIR}/.vortex/installer" install + + # Run the installer script from the local repository to allow debugging. + run php "${ROOT_DIR}/.vortex/installer/install" "${opt_quiet}" "$@" # Special treatment for cases where volumes are not mounted from the host. fix_host_dependencies "$@" @@ -1306,28 +1303,7 @@ download_installer() { rm -Rf "install.php" >/dev/null || true - git init >/dev/null - - if ! git remote | grep -q "installer_origin"; then - git remote add installer_origin "https://github.com/drevops/vortex-installer.git" >/dev/null - fi - - git fetch installer_origin "${TEST_INSTALLER_REF}" >/dev/null - - # LCOV_EXCL_START - if git branch -a | grep -q "remotes/installer_origin/${TEST_INSTALLER_REF}$"; then - echo "Checking out the installer from branch ref: ${TEST_INSTALLER_REF}" >&3 - git checkout "${TEST_INSTALLER_REF}" >/dev/null - elif git cat-file -t "${TEST_INSTALLER_REF}" >/dev/null 2>&1; then - echo "Checking out the installer from commit ref: ${TEST_INSTALLER_REF}" >&3 - git checkout "${TEST_INSTALLER_REF}" >/dev/null - else - echo "The provided reference does not match any branch or commit." >&3 - exit 1 - fi - # LCOV_EXCL_STOP - echo "Checkout successful." composer install --no-progress --no-suggest > /dev/null 2>&1 composer build > /dev/null 2>&1