diff --git a/.github/workflows/vortex-release-docs.yml b/.github/workflows/vortex-release-docs.yml
index 8e47e7637..7625916d9 100644
--- a/.github/workflows/vortex-release-docs.yml
+++ b/.github/workflows/vortex-release-docs.yml
@@ -7,6 +7,7 @@ on:
- '*'
branches:
- '**release-docs**'
+ - '**release-installer**'
permissions:
contents: read
@@ -18,7 +19,55 @@ concurrency:
cancel-in-progress: true
jobs:
- release-docs:
+
+ 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
+
+ vortex-release-docs:
+ needs: vortex-release-installer
+
environment:
name: github-pages
@@ -40,20 +89,30 @@ 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: |
+ cp ../installer ../.vortex/docs/static/install
+ php ../.vortex/docs/static/install --version
+
- name: Check docs up-to-date
run: |
composer --working-dir=.utils install
npm run update-variables
git diff --quiet HEAD || { echo "Docs not up-to-date. Run 'cd .vortex && ahoy update-docs' and commit changes."; git diff; exit 1; }
- working-directory: '${{ github.workspace }}/.vortex/docs'
+ working-directory: .vortex/docs
- name: Install dependencies
run: npm install
- working-directory: '${{ github.workspace }}/.vortex/docs'
+ working-directory: .vortex/docs
- name: Build documentation site
run: npm run build
- working-directory: '${{ github.workspace }}/.vortex/docs'
+ working-directory: .vortex/docs
- name: Upload documentation site
uses: actions/upload-pages-artifact@v3
diff --git a/.github/workflows/vortex-test-installer.yml b/.github/workflows/vortex-test-installer.yml
new file mode 100644
index 000000000..1579acfb8
--- /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/docs/content/README.mdx b/.vortex/docs/content/README.mdx
index f9818af7c..41db109c9 100644
--- a/.vortex/docs/content/README.mdx
+++ b/.vortex/docs/content/README.mdx
@@ -40,7 +40,7 @@ Refer to [Features](getting-started/features.mdx) for more details.
## Quick start
```bash
-curl -SsL https://install.drevops.com > install.php && php install.php
+curl -SsL https://vortex.drevops.com/install > install.php && php install.php
```
Refer to [Installation](getting-started/installation.mdx) for more details.
diff --git a/.vortex/docs/content/getting-started/features.mdx b/.vortex/docs/content/getting-started/features.mdx
index aa009ee92..70c6c826b 100644
--- a/.vortex/docs/content/getting-started/features.mdx
+++ b/.vortex/docs/content/getting-started/features.mdx
@@ -74,7 +74,7 @@ The following list includes ā
completed and š§ upcoming features.
* ā
Functional test coverage for workflows
* ā
Integration test coverage for deployments
* ā
Vortex updates
- * ā
[Basic installer](https://install.drevops.com/)
+ * ā
[Basic installer](https://vortex.drevops.com/install)
* š§ Advanced installer CLI UI
* š§ Advances installer Web UI
* š§ Automated project setup
diff --git a/.vortex/docs/content/getting-started/installation.mdx b/.vortex/docs/content/getting-started/installation.mdx
index 09c757707..e0d9f1697 100644
--- a/.vortex/docs/content/getting-started/installation.mdx
+++ b/.vortex/docs/content/getting-started/installation.mdx
@@ -17,7 +17,7 @@ for each service.
2. Run the following command to install the **Vortex** template.
```shell title="Install using interactive prompts"
- curl -SsL https://install.drevops.com > install.php && php install.php
+ curl -SsL https://vortex.drevops.com/install > install.php && php install.php
```
3. Run the following command to initialize Git repository and commit the initial
@@ -51,7 +51,7 @@ your existing project into the new one.
4. Run the following command to install the **Vortex** template.
```shell title="Install using interactive prompts"
- curl -SsL https://install.drevops.com > install.php && php install.php
+ curl -SsL https://vortex.drevops.com/install > install.php && php install.php
```
5. Commit the changes to the new branch.
diff --git a/.vortex/docs/content/workflows/variables.mdx b/.vortex/docs/content/workflows/variables.mdx
index f0e4d6e34..0337cbb53 100644
--- a/.vortex/docs/content/workflows/variables.mdx
+++ b/.vortex/docs/content/workflows/variables.mdx
@@ -1033,7 +1033,7 @@ Defined in: `scripts/vortex/github-labels.sh`
The URL of the installer script.
-Default value: `https://install.drevops.com`
+Default value: `https://vortex.drevops.com/install`
Defined in: `scripts/vortex/update-vortex.sh`
diff --git a/.vortex/installer/.gitignore b/.vortex/installer/.gitignore
new file mode 100644
index 000000000..26b0e2065
--- /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..fdb98b049
--- /dev/null
+++ b/.vortex/installer/README.md
@@ -0,0 +1,13 @@
+# Vortex installer
+
+## Maintenance
+
+ composer install
+ composer lint
+ composer test
+
+### Releasing
+
+The installer is packaged as a PHAR and deployed to https://vortex.drevops.com/install
+upon each GitHub release or for every branch to a branch containing the
+`release-docs` or `release-installer` in the name.
diff --git a/.vortex/installer/box.json b/.vortex/installer/box.json
new file mode 100644
index 000000000..362a4a070
--- /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://vortex.drevops.com/install > install.php && php 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
+
+
+
+
+
+
+
+
+
+
+
+
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
diff --git a/.vortex/tests/bats/update-vortex.bats b/.vortex/tests/bats/update-vortex.bats
index 7b870e201..2e3dbc5e4 100644
--- a/.vortex/tests/bats/update-vortex.bats
+++ b/.vortex/tests/bats/update-vortex.bats
@@ -47,8 +47,14 @@ load _helper.bash
echo VORTEX_INSTALL_COMMIT="${latest_commit}" >>.env
# Enforce debugging of the install script.
export VORTEX_INSTALL_DEBUG=1
+
+ # Build installer.
+ [ ! -d "${ROOT_DIR}/.vortex/installer/vendor" ] && composer --working-dir="${ROOT_DIR}/.vortex/installer" install
+ [ ! -d "${ROOT_DIR}/.vortex/installer/build" ] && composer --working-dir="${ROOT_DIR}/.vortex/installer" build
+ assert_file_exists "${ROOT_DIR}/.vortex/installer/build/installer"
+
# Override install script with currently tested one to be called from ./scripts/vortex/update-vortex.sh
- export VORTEX_INSTALLER_URL="file://${INSTALLER_CHECKOUT_DIR}/install.php"
+ export VORTEX_INSTALLER_URL="file://${ROOT_DIR}/.vortex/installer/build/installer"
# shellcheck disable=SC2059
run ahoy update-vortex
assert_success
diff --git a/README.md b/README.md
index 60d2b51eb..f08a05396 100644
--- a/README.md
+++ b/README.md
@@ -35,9 +35,7 @@ Track our current progress and view planned updates on [the GitHub project board
Our [installer](https://github.com/drevops/vortex-installer) simplifies setup, letting you choose only the features you need. It will integrate the latest Vortex release into your codebase and you will choose which changes to commit.
```bash
-curl -SsL https://install.drevops.com > install.php
-php install.php
-rm -r install.php
+curl -SsL https://vortex.drevops.com/install > install.php && php install.php
```
Alternatively, clone this repository and adjust the configuration by manually
diff --git a/scripts/vortex/update-vortex.sh b/scripts/vortex/update-vortex.sh
index ae102b9e6..f3e8874e2 100755
--- a/scripts/vortex/update-vortex.sh
+++ b/scripts/vortex/update-vortex.sh
@@ -17,7 +17,7 @@ set -a && . ./.env && set +a
VORTEX_INSTALL_COMMIT="${VORTEX_INSTALL_COMMIT:-${1:-}}"
# The URL of the installer script.
-VORTEX_INSTALLER_URL="${VORTEX_INSTALLER_URL:-https://install.drevops.com}"
+VORTEX_INSTALLER_URL="${VORTEX_INSTALLER_URL:-https://vortex.drevops.com/install}"
# ------------------------------------------------------------------------------