diff --git a/.github/workflows/vortex-release.yml b/.github/workflows/vortex-release.yml
index c6011c133..1f49a28ee 100644
--- a/.github/workflows/vortex-release.yml
+++ b/.github/workflows/vortex-release.yml
@@ -77,7 +77,7 @@ jobs:
- name: Test PHAR
run: |
./build/installer.phar --version
- ./build/installer.phar --no-interaction --no-cleanup test || exit 1
+ ./build/installer.phar --no-interaction --no-cleanup --destination=test || exit 1
working-directory: .vortex/installer
- name: Upload artifact
diff --git a/.github/workflows/vortex-test-installer.yml b/.github/workflows/vortex-test-installer.yml
index d25127d09..48ebaf3fa 100644
--- a/.github/workflows/vortex-test-installer.yml
+++ b/.github/workflows/vortex-test-installer.yml
@@ -86,7 +86,7 @@ jobs:
working-directory: .vortex/installer
- name: Test PHAR
- run: ./build/installer.phar --no-interaction example || exit 1
+ run: ./build/installer.phar --no-interaction --destination=example || exit 1
working-directory: .vortex/installer
env:
GITHUB_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
diff --git a/.vortex/docs/.utils/update-installer-video.sh b/.vortex/docs/.utils/update-installer-video.sh
index 6ec818a8f..1b6173389 100755
--- a/.vortex/docs/.utils/update-installer-video.sh
+++ b/.vortex/docs/.utils/update-installer-video.sh
@@ -183,7 +183,8 @@ proc wait_and_enter {} {
#######################
# Start the installer #
#######################
-spawn php installer.php star_wars
+set env(VORTEX_INSTALLER_PROMPT_BUILD_NOW) 0
+spawn php installer.php --destination=star_wars
# Wait for the welcome screen and let it proceed
expect {
@@ -235,13 +236,13 @@ while {1} {
after 2000
safe_send "\r"
}
- "─┘" {
- wait_and_enter
- }
"Finished installing Vortex" {
# Installation completed, break out of loop
break
}
+ "─┘" {
+ wait_and_enter
+ }
timeout {
puts "Timeout during installation"
break
@@ -251,7 +252,22 @@ while {1} {
break
}
}
-# sleep 1
+}
+
+# Handle the final "Run the site build now?" prompt separately
+# Default is "No" via VORTEX_INSTALLER_PROMPT_BUILD_NOW=0 env var
+expect {
+ "Run the site build now?" {
+ after 2000
+ # Just press Enter to accept the default (No)
+ safe_send "\r"
+ }
+ timeout {
+ puts "Timeout waiting for build prompt"
+ }
+ eof {
+ puts "End of file before build prompt"
+ }
}
expect eof
diff --git a/.vortex/docs/static/img/installer.json b/.vortex/docs/static/img/installer.json
index 65ad35060..4b2c8fbae 100644
--- a/.vortex/docs/static/img/installer.json
+++ b/.vortex/docs/static/img/installer.json
@@ -1,170 +1,186 @@
-{"version": 2, "width": 120, "height": 36, "timestamp": 1763622409, "env": {"SHELL": "/opt/homebrew/opt/bash/bin/bash", "TERM": "xterm-256color"}, "title": "Vortex Installer Demo"}
-[0.478342, "o", "\r\r\n \u001b[36m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[39m\r\r\n \u001b[36m\u001b[39m\r\r\n \u001b[36m\u001b[39m\r\r\n \u001b[36m ██╗ ██╗ ██████╗ ██████╗ ████████╗ ███████╗ ██╗ ██╗\u001b[39m\r\r\n \u001b[36m ██║ ██║ ██╔═══██╗ ██╔══██╗ ╚══██╔══╝ ██╔════╝ ╚██╗██╔╝\u001b[39m\r\r\n \u001b[36m ██║ ██║ ██║ ██║ ██████╔╝ ██║ █████╗ ╚███╔╝\u001b[39m\r\r\n \u001b[36m ╚██╗ ██╔╝ █"]
-[0.478515, "o", "█║ ██║ ██╔══██╗ ██║ ██╔══╝ ██╔██╗\u001b[39m\r\r\n \u001b[36m ╚████╔╝ ╚██████╔╝ ██║ ██║ ██║ ███████╗ ██╔╝ ██╗\u001b[39m\r\r\n \u001b[36m ╚═══╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝\u001b[39m\r\r\n \u001b[36m\u001b[39m\r\r\n \u001b[36m Drupal project template\u001b[39m\r\r\n \u001b[36m\u001b[39m\r\r\n \u001b[36m by DrevOps\u001b[39m\r\r\n \u001b[36m\u001b[39m\r\r\n \u001b[36m─────────────────────────────────────────────────────────────────────────────────────────────────────────────"]
-[0.478595, "o", "─────"]
-[0.478629, "o", "────\u001b[39m\r\r\n \u001b[2m Installer version: development\u001b[22m\r\r\n\r\r\n"]
-[0.481616, "o", "\r\r\n \u001b[90m┌──────────────────────────────────────────────────────────────────────────────────────┐\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[32mWelcome to the Vortex interactive installer\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[32m───────────────────────────────────────────\u001b[39m\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m This tool will guide you through installing the latest \u001b[4mstable\u001b[0m version of Vortex into\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m your project.\u001b[39m\u001b[39m "]
-[0.48171, "o", " \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m You will be asked a few questions to tailor the configuration to your site.\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m No changes will be made until you confirm everything at the end.\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Press \u001b[33mCtrl+C\u001b[39m at any time to exit the installer.\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Press \u001b[33mCtrl+U\u001b[39m at any time to go back to the previous step.\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m└───────"]
-[0.481783, "o", "─────"]
-[0.481892, "o", "──────────────────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n \u001b[2mPress any key to continue...\u001b[22m\r\r\n"]
-[3.494352, "o", "\r\r\n \u001b[46m\u001b[30m General information \u001b[39m\u001b[49m\r\r\n\r\r\n"]
-[3.506182, "o", "\u001b[?25l"]
-[3.51349, "o", "\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mSite name \u001b[2m(1/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m star wars\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name in the project and documentation.\u001b[39m\r\r\n"]
-[3.667991, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mSite name \u001b[2m(1/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m\u001b[7mE\u001b[27m.g. My Site\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name in the project and documentation.\u001b[39m\r\r\n"]
-[4.705685, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mSite name \u001b[2m(1/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m S\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name in the project and documentation.\u001b[39m\r\r\n\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mSite name \u001b[2m(1/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m St\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └─────────────────────────────────────"]
-[4.705925, "o", "──"]
-[4.706003, "o", "───────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name in the project and documentation.\u001b[39m\r\r\n"]
-[5.710695, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mSite name \u001b[2m(1/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Star Wars\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name in the project and documentation.\u001b[39m\r\r\n"]
-[5.713612, "o", "\u001b[1G"]
-[5.71364, "o", "\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mSite name \u001b[2m(1/29)\u001b[22m\u001b[22m \u001b[90m────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Star Wars \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n"]
-[5.713793, "o", "\u001b[?25h"]
-[5.732882, "o", "\u001b[?25l"]
-[5.73602, "o", "\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mSite machine name \u001b[2m(2/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m star_wars\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name for the project directory and in the code.\u001b[39m\r\r\n"]
-[6.751806, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mSite machine name \u001b[2m(2/29)\u001b[22m\u001b[22m \u001b[90m────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m star_wars \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n"]
-[6.75192, "o", "\u001b[?25h"]
-[6.768354, "o", "\u001b[?25l"]
-[6.770646, "o", "\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mOrganization name \u001b[2m(3/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Star Wars Org\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name in the project and documentation.\u001b[39m\r\r\n"]
-[6.925847, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mOrganization name \u001b[2m(3/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Star Wars \u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name in the project and documentation.\u001b[39m\r\r\n\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mOrganization name \u001b[2m(3/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m\u001b[7mE\u001b[27m.g. My Org\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └────────────────────────────────────────────"]
-[6.926041, "o", "───"]
-[7.954645, "o", "───────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name in the project and documentation.\u001b[39m\r\r\n\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mOrganization name \u001b[2m(3/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m R\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name in the project and documentation.\u001b[39m\r\r\n\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mOrganization name \u001b[2m(3/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Re\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └─────"]
-[7.954857, "o", "────"]
-[7.955289, "o", "─────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name in the project and documentation.\u001b[39m\r\r\n"]
-[8.959141, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mOrganization name \u001b[2m(3/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Rebellion\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name in the project and documentation.\u001b[39m\r\r\n"]
-[9.964259, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mOrganization name \u001b[2m(3/29)\u001b[22m\u001b[22m \u001b[90m────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Rebellion \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mOrganization machine name \u001b[2m(4/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m rebellion\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name in the co"]
-[9.96435, "o", "de.\u001b[39m\r"]
-[9.964386, "o", "\r\r\n"]
-[10.970065, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mOrganization machine name \u001b[2m(4/29)\u001b[22m\u001b[22m \u001b[90m────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m rebellion \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mPublic domain \u001b[2m(5/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m star-wars.com\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Domain name without pro"]
-[10.970181, "o", "tocol and"]
-[10.970276, "o", " trailing slash.\u001b[39m\r\r\n"]
-[11.974107, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mPublic domain \u001b[2m(5/29)\u001b[22m\u001b[22m \u001b[90m────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m star-wars.com \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\r\r\n \u001b[46m\u001b[30m Drupal \u001b[39m\u001b[49m\r\r\n\r\r\n\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mHow would you like your site to be created on the first run? \u001b[2m(6/29)\u001b[22m\u001b[39m \u001b[90m┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Choose how your site will be created the first time after this \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m installer finishes: \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m "]
-[11.97428, "o", " "]
-[11.974702, "o", " \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mDrupal, installed from profile\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Creates a new site by \u001b[4mpopulating a fresh database\u001b[0m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m from one of the standard Drupal installation profiles. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mDrupal CMS, installed from profile\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Creates a new site by \u001b[4mpopulating a fresh database\u001b[0m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m from the Drupal CMS recipe. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mDrupal, loaded from the demo database\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Creates a site by \u001b[4mloadin"]
-[11.974849, "o", "g an exist"]
-[12.977637, "o", "ing demo database\u001b[0m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m provided with the installer. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m ├─────────────────────────────────────────────────────────────────────┤\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mDrupal, installed from profile\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mDrupal CMS, installed from profile\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m›\u001b[39m \u001b[36m●\u001b[39m Drupal, loaded from the demo database \u001b[90m│\u001b[39m\r\r\n\u001b[90m └────────────────────────────────────────────────────────────"]
-[12.977727, "o", "──"]
-[12.977764, "o", "───────┘\u001b[39m\r\r\n\u001b[90m Use ⬆ and ⬇. Applies only on the first run of the installer.\u001b[39m\r\r\n"]
-[13.999325, "o", "\u001b[1G\u001b[24A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mHow would you like your site to be created on the first run? \u001b[2m(6/29)\u001b[22m\u001b[22m \u001b[90m┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Choose how your site will be created the first time after this \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m installer finishes: \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mDrupal, installed from profile\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Creates a new site by \u001b[4mpopulating a fresh database\u001b[0m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m from one of the standard Drupal installation profiles. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mDrupal CMS, installed from profile\u001b[22m "]
-[13.999488, "o", " "]
-[13.999663, "o", " \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Creates a new site by \u001b[4mpopulating a fresh database\u001b[0m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m from the Drupal CMS recipe. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mDrupal, loaded from the demo database\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Creates a site by \u001b[4mloading an existing demo database\u001b[0m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m provided with the installer. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m ├─────────────────────────────────────────────────────────────────────┤\u001b[39m\r\r\n\u001b[90m │\u001b[39m Drupal, loaded from the demo database "]
-[13.999676, "o", " "]
-[13.999749, "o", " \u001b[90m│\u001b[39m\r\r\n\u001b[90m └─────────────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h"]
-[15.004475, "o", "\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mProfile \u001b[2m(7/29)\u001b[22m\u001b[39m \u001b[90m──────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m›\u001b[39m \u001b[36m●\u001b[39m Standard \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mMinimal\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mDemo Umami\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mCustom (next prompt)\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆ and ⬇ to select which Drupal profile to use.\u001b[39m\r\r\n"]
-[16.009791, "o", "\u001b[1G\u001b[8A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mProfile \u001b[2m(7/29)\u001b[22m\u001b[22m \u001b[90m──────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Standard \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mModules \u001b[2m(8/29)\u001b[22m\u001b[39m \u001b[90m──────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m› ◼\u001b[39m Admin toolbar \u001b[36m┃\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mCoffee\u001b[22m \u001b[90m│\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mConfig spli"]
-[16.010105, "o", "t\u001b[22m "]
-[17.289367, "o", " \u001b[90m│\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mConfig update\u001b[22m \u001b[90m│\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mEnvironment indicator\u001b[22m \u001b[90m│\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mPathauto\u001b[22m \u001b[90m│\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mRedirect\u001b[22m \u001b[90m│\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mRobots.txt\u001b[22m \u001b[90m│\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mSeckit\u001b[22m \u001b[90m│\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mShield\u001b[22m \u001b[90m│\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └───────────────────────"]
-[17.28958, "o", "────────────────────────── 11 selected ┘\u001b[39m\r\r\n\u001b[90m Use ⬆, ⬇ and Space bar to select one or more modules.\u001b[39m\r\r\n\u001b[1G\u001b[14A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mModules \u001b[2m(8/29)\u001b[22m\u001b[22m \u001b[90m──────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Admin toolbar \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Coffee \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Config split \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Config update \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Environment indicator \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Pathauto \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Redirect \u001b[90m"]
-[17.289607, "o", "│\u001b[39m\r\r\n\u001b[90m │"]
-[17.28991, "o", "\u001b[39m Robots.txt \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Seckit \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Shield \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Stage file proxy \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mCustom modules prefix \u001b[2m(9/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m sw\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └────────────────────────────────────────────────────"]
-[17.290649, "o", "───"]
-[17.29079, "o", "───────┘\u001b[39m\r\r\n\u001b[90m We will use this name in custom modules\u001b[39m\r\r\n"]
-[18.294279, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mCustom modules prefix \u001b[2m(9/29)\u001b[22m\u001b[22m \u001b[90m────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m sw \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mTheme \u001b[2m(10/29)\u001b[22m\u001b[39m \u001b[90m───────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mOlivero\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mClaro\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mStark\u001b[22m "]
-[18.294948, "o", " \u001b[90"]
-[18.298906, "o", "m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m›\u001b[39m \u001b[36m●\u001b[39m Custom (next prompt) \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆ and ⬇ to select which Drupal theme to use.\u001b[39m\r\r\n"]
-[19.301136, "o", "\u001b[1G\u001b[8A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mTheme \u001b[2m(10/29)\u001b[22m\u001b[22m \u001b[90m───────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Custom (next prompt) \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mCustom theme machine name \u001b[2m(11/29)\u001b[22m\u001b[39m \u001b[90m───────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m star_wars\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use"]
-[19.302079, "o", " this nam"]
-[19.304146, "o", "e as a custom theme name\u001b[39m\r\r\n"]
-[20.307642, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mCustom theme machine name \u001b[2m(11/29)\u001b[22m\u001b[22m \u001b[90m───────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m star_wars \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\r\r\n \u001b[46m\u001b[30m Code repository \u001b[39m\u001b[49m\r\r\n\r\r\n\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mRepository provider \u001b[2m(12/29)\u001b[22m\u001b[39m \u001b[90m─────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Vortex offers full automation with GitHub, while support for \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m other providers is limited. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m "]
-[20.307813, "o", " "]
-[20.307868, "o", " \u001b[90m│\u001b[39m\r\r\n\u001b[90m ├──────────────────────────────────────────────────────────────┤\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m›\u001b[39m \u001b[36m●\u001b[39m GitHub \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mOther\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆ and ⬇ to select your code repository provider.\u001b[39m\r\r\n"]
-[21.310876, "o", "\u001b[1G\u001b[11A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mRepository provider \u001b[2m(12/29)\u001b[22m\u001b[22m \u001b[90m─────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Vortex offers full automation with GitHub, while support for \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m other providers is limited. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m ├──────────────────────────────────────────────────────────────┤\u001b[39m\r\r\n\u001b[90m │\u001b[39m GitHub \u001b[90m│\u001b[39m\r\r\n\u001b[90m └────────────────────────────────────────────────────"]
-[21.311123, "o", "──────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h"]
-[22.315529, "o", "\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mRelease versioning scheme \u001b[2m(13/29)\u001b[22m\u001b[39m \u001b[90m───────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Choose your versioning scheme: \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mCalendar Versioning (CalVer)\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[4myear.month.patch\u001b[0m (E.g., \u001b[4m24.1.0\u001b[0m) \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m https://calver.org \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mSemantic Versioning (SemVer)\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[4mmajor.minor.patch\u001b[0m (E.g., \u001b[4m1.0.0\u001b[0m) "]
-[22.315602, "o", " "]
-[22.315779, "o", " \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m https://semver.org \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mOther\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Custom versioning scheme of your choice. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m ├──────────────────────────────────────────────────────────────┤\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m›\u001b[39m \u001b[36m●\u001b[39m Calendar Versioning (CalVer) \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mSemantic Versioning (SemVer)\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mOther\u001b[22m \u001b"]
-[22.315795, "o", "[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆ and ⬇ to select your version scheme.\u001b[39m\r\r\n"]
-[23.35745, "o", "\u001b[1G\u001b[22A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mRelease versioning scheme \u001b[2m(13/29)\u001b[22m\u001b[22m \u001b[90m───────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Choose your versioning scheme: \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mCalendar Versioning (CalVer)\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[4myear.month.patch\u001b[0m (E.g., \u001b[4m24.1.0\u001b[0m) \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m https://calver.org \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mSemantic Versioning (SemVer)\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[4mmajor.minor.patch\u001b[0m (E.g., \u001b[4m1.0.0\u001b[0m) "]
-[23.357564, "o", " "]
-[23.357592, "o", " \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m https://semver.org \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mOther\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Custom versioning scheme of your choice. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m ├──────────────────────────────────────────────────────────────┤\u001b[39m\r\r\n\u001b[90m │\u001b[39m Calendar Versioning (CalVer) \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h"]
-[24.359229, "o", "\r\r\n \u001b[46m\u001b[30m Environment \u001b[39m\u001b[49m\r\r\n\r\r\n\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mTimezone \u001b[2m(14/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m UTC\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m ├──────────────────────────────────────────────────────────────┤\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2mUTC\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆ and ⬇, or start typing to select the timezone for your project.\u001b[39m\r\r\n"]
-[25.365169, "o", "\u001b[1G\u001b[7A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mTimezone \u001b[2m(14/29)\u001b[22m\u001b[22m \u001b[90m────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m UTC \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mServices \u001b[2m(15/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m› ◼\u001b[39m ClamAV \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mSolr\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mRedis\u001b[22m "]
-[25.365311, "o", " "]
-[25.365472, "o", " \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆, ⬇ and Space bar to select one or more services.\u001b[39m\r\r\n"]
-[26.370513, "o", "\u001b[1G\u001b[7A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mServices \u001b[2m(15/29)\u001b[22m\u001b[22m \u001b[90m────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m ClamAV \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Solr \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Redis \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mDevelopment tools \u001b[2m(16/29)\u001b[22m\u001b[39m \u001b[90m───────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m› ◼\u001b[39m PHP CodeSniffer \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼"]
-[26.370636, "o", "\u001b[39m \u001b[2m"]
-[26.370799, "o", "PHPStan\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mRector\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mPHP Mess Detector\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mPHPUnit\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mBehat\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆, ⬇ and Space bar to select one or more tools.\u001b[39m\r\r\n"]
-[27.375616, "o", "\u001b[1G\u001b[10A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mDevelopment tools \u001b[2m(16/29)\u001b[22m\u001b[22m \u001b[90m───────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m PHP CodeSniffer \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m PHPStan \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Rector \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m PHP Mess Detector \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m PHPUnit \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Behat \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\r\r\n \u001b[46m\u001b[30m Hosting \u001b[39m\u001b[49m\r\r\n\r\r\n\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m"]
-[27.375784, "o", " \u001b[36mHosting "]
-[27.37598, "o", "provider \u001b[2m(17/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mAcquia Cloud\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mLagoon\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mOther\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m›\u001b[39m \u001b[36m●\u001b[39m None \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆, ⬇ and Space bar to select your hosting provider.\u001b[39m\r\r\n"]
-[28.380188, "o", "\u001b[1G\u001b[8A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mHosting provider \u001b[2m(17/29)\u001b[22m\u001b[22m \u001b[90m────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m None \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mCustom web root directory \u001b[2m(18/29)\u001b[22m\u001b[39m \u001b[90m───────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m web\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Custom directory where the web se"]
-[28.38028, "o", "rver serv"]
-[28.380413, "o", "es the site.\u001b[39m\r\r\n"]
-[29.383619, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mCustom web root directory \u001b[2m(18/29)\u001b[22m\u001b[22m \u001b[90m───────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m web \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\r\r\n \u001b[46m\u001b[30m Deployment \u001b[39m\u001b[49m\r\r\n\r\r\n\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mDeployment types \u001b[2m(19/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m›\u001b[39m ◻ Code artifact \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m◻\u001b[22m \u001b[2mLagoon webhook\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m◻\u001b[22m \u001b[2mContainer image\u001b[22m "]
-[29.383741, "o", " \u001b[9"]
-[29.383809, "o", "0m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mCustom webhook\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆, ⬇ and Space bar to select one or more deployment types.\u001b[39m\r\r\n"]
-[30.388074, "o", "\u001b[1G\u001b[8A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mDeployment types \u001b[2m(19/29)\u001b[22m\u001b[22m \u001b[90m────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Custom webhook \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\r\r\n \u001b[46m\u001b[30m Workflow \u001b[39m\u001b[49m\r\r\n\r\r\n\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mProvision type \u001b[2m(20/29)\u001b[22m\u001b[39m \u001b[90m──────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Provisioning sets up the site in an environment using an \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m already assembled codebase. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │"]
-[30.388172, "o", "\u001b[39m "]
-[30.389685, "o", " \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mImport from database dump\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Provisions the site by importing a database dump \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m typically copied from production into lower \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m environments. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mInstall from profile\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Provisions the site by installing a fresh Drupal \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m site from a profile every time an environment is \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m created. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m ├─"]
-[30.389705, "o", "───"]
-[31.393218, "o", "──────────────────────────────────────────────────────────┤\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m›\u001b[39m \u001b[36m●\u001b[39m Import from database dump \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mInstall from profile\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆ and ⬇ to select the provision type.\u001b[39m\r\r\n\u001b[1G\u001b[21A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mProvision type \u001b[2m(20/29)\u001b[22m\u001b[22m \u001b[90m──────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Provisioning sets up the site in an e"]
-[31.393369, "o", "nvironment using an \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m already assembled codebase. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mImport from database dump\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Provisions the site by importing a database dump \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m typically copied from production into lower \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m environments. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mInstall from profile\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Provisions the site by installing a fresh Drupal \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m site from a profile every time an environment is \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m created. "]
-[31.393451, "o", " "]
-[32.396823, "o", " \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m ├──────────────────────────────────────────────────────────────┤\u001b[39m\r\r\n\u001b[90m │\u001b[39m Import from database dump \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mDatabase source \u001b[2m(21/29)\u001b[22m\u001b[39m \u001b[90m─────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m›\u001b[39m \u001b[36m●\u001b[39m URL download \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mFTP download\u001b[22m "]
-[32.396966, "o", " "]
-[32.397028, "o", " \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mAcquia backup\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mLagoon environment\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mContainer registry\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mNone\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆ and ⬇ to select the database download source.\u001b[39m\r\r\n"]
-[33.403406, "o", "\u001b[1G\u001b[10A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mDatabase source \u001b[2m(21/29)\u001b[22m\u001b[22m \u001b[90m─────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m URL download \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h"]
-[34.407552, "o", "\r\r\n \u001b[46m\u001b[30m Notifications \u001b[39m\u001b[49m\r\r\n\r\r\n\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mNotification channels \u001b[2m(22/29)\u001b[22m\u001b[39m \u001b[90m───────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m› ◼\u001b[39m Email \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m◻\u001b[22m \u001b[2mGitHub\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m◻\u001b[22m \u001b[2mJIRA\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m◻\u001b[22m \u001b[2mNew Relic\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m◻\u001b[22m \u001b[2mSlack\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m◻\u001b[22m \u001b[2mWebhook\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └────────────────────────────────────────"]
-[34.407722, "o", "──────────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆, ⬇ and Space bar to select one or more notification channels.\u001b[39m\r\r\n"]
-[35.413038, "o", "\u001b[1G\u001b[10A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mNotification channels \u001b[2m(22/29)\u001b[22m\u001b[22m \u001b[90m───────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Email \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\r\r\n \u001b[46m\u001b[30m Continuous Integration \u001b[39m\u001b[49m\r\r\n\r\r\n\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mContinuous Integration provider \u001b[2m(23/29)\u001b[22m\u001b[39m \u001b[90m─────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m›\u001b[39m \u001b[36m●\u001b[39m GitHub Actions \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mCircleCI\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mNone\u001b[22m "]
-[35.41311, "o", " \u001b["]
-[35.413275, "o", "90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆ and ⬇ to select the CI provider.\u001b[39m\r\r\n"]
-[36.417213, "o", "\u001b[1G\u001b[7A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mContinuous Integration provider \u001b[2m(23/29)\u001b[22m\u001b[22m \u001b[90m─────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m GitHub Actions \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\r\r\n \u001b[46m\u001b[30m Automations \u001b[39m\u001b[49m\r\r\n\r\r\n\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mDependency updates provider \u001b[2m(24/29)\u001b[22m\u001b[39m \u001b[90m─────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m›\u001b[39m \u001b[36m●\u001b[39m Renovate GitHub app \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mRenovate self-hosted in CI\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mNone\u001b[22m \u001b[90m│\u001b[39m\r"]
-[36.417341, "o", "\r\n\u001b[90m └"]
-[36.417402, "o", "──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆ and ⬇ to select the dependency updates provider.\u001b[39m\r\r\n"]
-[37.422067, "o", "\u001b[1G\u001b[7A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mDependency updates provider \u001b[2m(24/29)\u001b[22m\u001b[22m \u001b[90m─────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Renovate GitHub app \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mAuto-assign the author to their PR? \u001b[2m(25/29)\u001b[22m\u001b[39m \u001b[90m─────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[32m●\u001b[39m Yes \u001b[2m/ ○ No\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Helps to keep the PRs organized.\u001b[39m\r\r\n"]
-[38.426261, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mAuto-assign the author to their PR? \u001b[2m(25/29)\u001b[22m\u001b[22m \u001b[90m─────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Yes \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mAuto-add a CONFLICT label to a PR when conflicts occur? \u001b[2m(26/29)\u001b[22m\u001b[39m \u001b[90m┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[32m●\u001b[39m Yes \u001b[2m/ ○ No\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └─────────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Helps to keep quickly identify PRs that need attention.\u001b[39m\r\r\n"]
-[39.430198, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mAuto-add a CONFLICT label to a PR when conflicts occur? \u001b[2m(26/29)\u001b[22m\u001b[22m \u001b[90m┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Yes \u001b[90m│\u001b[39m\r\r\n\u001b[90m └─────────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\r\r\n \u001b[46m\u001b[30m Documentation \u001b[39m\u001b[49m\r\r\n\r\r\n\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mPreserve project documentation? \u001b[2m(27/29)\u001b[22m\u001b[39m \u001b[90m─────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[32m●\u001b[39m Yes \u001b[2m/ ○ No\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Helps to maintain the project documentation"]
-[39.430306, "o", " within the "]
-[39.430341, "o", "repository.\u001b[39m\r\r\n"]
-[40.434917, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mPreserve project documentation? \u001b[2m(27/29)\u001b[22m\u001b[22m \u001b[90m─────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Yes \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\r\r\n \u001b[46m\u001b[30m AI \u001b[39m\u001b[49m\r\r\n\r\r\n\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mAI code assistant instructions \u001b[2m(28/29)\u001b[22m\u001b[39m \u001b[90m──────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m›\u001b[39m \u001b[36m●\u001b[39m Anthropic Claude \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mNone\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────"]
-[40.435179, "o", "────────────────────────┘\u001b[39m\r\r\n\u001b[90m Provides AI coding assistants with better context about the project.\u001b[39m\r\r\n"]
-[41.438358, "o", "\u001b[1G\u001b[6A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mAI code assistant instructions \u001b[2m(28/29)\u001b[22m\u001b[22m \u001b[90m──────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Anthropic Claude \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\r\r\n \u001b[46m\u001b[30m \u001b[39m\u001b[49m\r\r\n \u001b[46m\u001b[30m Installation summary \u001b[39m\u001b[49m\r\r\n \u001b[46m\u001b[30m \u001b[39m\u001b[49m\r\r\n\r\r\n\r\r\n \u001b[90m┌────────────────────────────────────┬─────────────────────────────────────────┐\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[36m\u001b[1mGeneral information\u001b[22m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m"]
-[41.439698, "o", "│\u001b[39m\r\r\n \u001b"]
-[41.440978, "o", "[90m│\u001b[39m\u001b[39m Site name \u001b[39m\u001b[90m│\u001b[39m\u001b[39m Star Wars \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Site machine name \u001b[39m\u001b[90m│\u001b[39m\u001b[39m star_wars \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Organization name \u001b[39m\u001b[90m│\u001b[39m\u001b[39m Rebellion \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Organization machine name \u001b[39m\u001b[90m│\u001b[39m\u001b[39m rebellion \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Public domain \u001b[39m\u001b[90m│\u001b[39m\u001b[39m star-wars.com \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[36m\u001b[1mDrupal\u001b[22m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Starter \u001b[39m\u001b[90m│\u001b[39m\u001b[39m load_demodb \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Modul"]
-[41.444719, "o", "es "]
-[42.449748, "o", " \u001b[39m\u001b[90m│\u001b[39m\u001b[39m admin_toolbar, coffee, config_split,\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m config_update, environment_indicator,\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m pathauto, redirect, robotstxt, seckit,\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m shield, stage_file_proxy \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Webroot \u001b[39m\u001b[90m│\u001b[39m\u001b[39m web \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Profile \u001b[39m\u001b[90m│\u001b[39m\u001b[39m standard \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Module prefix \u001b[39m\u001b[90m│\u001b[39m\u001b[39m sw \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Theme machine name "]
-[42.449957, "o", " "]
-[42.450111, "o", "\u001b[39m\u001b[90m│\u001b[39m\u001b[39m star_wars \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[36m\u001b[1mCode repository\u001b[22m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Code provider \u001b[39m\u001b[90m│\u001b[39m\u001b[39m github \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Version scheme \u001b[39m\u001b[90m│\u001b[39m\u001b[39m calver \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[36m\u001b[1mEnvironment\u001b[22m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Timezone \u001b[39m\u001b[90m│\u001b[39m\u001b[39m UTC \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Services \u001b[39m\u001b[90m│\u001b[39m\u001b[39m clamav, redis, solr \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Tools \u001b[39m\u001b"]
-[42.450223, "o", "[90m│\u001b[39m\u001b[39m phpcs, phpmd, phpstan, rector, phpunit,\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m behat \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[36m\u001b[1mHosting\u001b[22m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Hosting provider \u001b[39m\u001b[90m│\u001b[39m\u001b[39m none \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[36m\u001b[1mDeployment\u001b[22m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Deployment types \u001b[39m\u001b[90m│\u001b[39m\u001b[39m webhook \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[36m\u001b[1mWorkflow\u001b[22m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Provision type "]
-[42.450301, "o", " "]
-[42.450404, "o", " \u001b[39m\u001b[90m│\u001b[39m\u001b[39m database \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Database source \u001b[39m\u001b[90m│\u001b[39m\u001b[39m url \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[36m\u001b[1mNotifications\u001b[22m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Channels \u001b[39m\u001b[90m│\u001b[39m\u001b[39m email \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[36m\u001b[1mContinuous Integration\u001b[22m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m CI provider \u001b[39m\u001b[90m│\u001b[39m\u001b[39m gha \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[36m\u001b[1mAutomations\u001b[22m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Dependency update"]
-[42.45043, "o", "s provi"]
-[42.450505, "o", "der \u001b[39m\u001b[90m│\u001b[39m\u001b[39m renovatebot_app \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Auto-assign PR author \u001b[39m\u001b[90m│\u001b[39m\u001b[39m Yes \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Auto-add a CONFLICT label to PRs \u001b[39m\u001b[90m│\u001b[39m\u001b[39m Yes \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[36m\u001b[1mDocumentation\u001b[22m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Preserve project documentation \u001b[39m\u001b[90m│\u001b[39m\u001b[39m Yes \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[36m\u001b[1mAI\u001b[22m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m AI code assistant instructions \u001b[39m\u001b[90m│\u001b[39m\u001b[39m claude \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[36m\u001b[1mLocations\u001b[22m\u001b[39m "]
-[42.450526, "o", " "]
-[42.450705, "o", " \u001b[39m\u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Current directory \u001b[39m\u001b[90m│\u001b[39m\u001b[39m /home/user/www/demo \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Destination directory \u001b[39m\u001b[90m│\u001b[39m\u001b[39m /home/user/www/demo/star_wars \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Vortex repository \u001b[39m\u001b[90m│\u001b[39m\u001b[39m https://github.com/drevops/vortex.git \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Vortex reference \u001b[39m\u001b[90m│\u001b[39m\u001b[39m stable \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m└────────────────────────────────────┴─────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n Vortex will be installed into your project's directory \"/home/user/www/demo/star_wars\"\r\r\n"]
-[44.455413, "o", "\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mProceed with installing Vortex?\u001b[39m \u001b[90m─────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[32m●\u001b[39m Yes \u001b[2m/ ○ No\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n"]
-[46.460936, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mProceed with installing Vortex?\u001b[22m \u001b[90m─────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Yes \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\r\r\n \u001b[46m\u001b[30m Starting project installation \u001b[39m\u001b[49m\r\r\n\r\r\n\u001b[?25l\r\r\n \u001b[36m⠂\u001b[39m \u001b[33mDownloading Vortex\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠒\u001b[39m \u001b[33mDownloading Vortex\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠐\u001b[39m \u001b[33mDownloading Vortex\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠰\u001b[39m \u001b[33mDownloading Vortex\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠠\u001b[39m \u001b[33mDownloading Vortex\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠤\u001b[39m \u001b[33mDownloading Vortex\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠄\u001b[39m \u001b[33mDownloading Vortex\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠆\u001b[39m \u001b[33mDownloading Vortex\u001b[39m\r\r\n\u001b[1G\u001b[2A"]
-[46.462314, "o", "\u001b[J\r\r\n \u001b[36m⠂\u001b[39m \u001b[33"]
-[47.465396, "o", "mDownloading Vortex\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠒\u001b[39m \u001b[33mDownloading Vortex\u001b[39m\r\r\n\u001b[999D\u001b[2A\u001b[J\u001b[?25h\r\r\n \u001b[34m✦ Downloading Vortex\u001b[39m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n\r\r\n \u001b[2mDownloading from \"https://github.com/drevops/vortex.git\" repository at commit \"HEAD\"\u001b[22m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n\r\r\n \u001b[32m✓ Vortex downloaded (25.10.0)\u001b[39m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n\u001b[?25l\r\r\n \u001b[36m⠂\u001b[39m \u001b[33mCustomizing Vortex for your project\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠒\u001b[39m \u001b[33mCustomizing Vortex for your project\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠐\u001b[39m \u001b[33mCustomizing Vortex for your project\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠰\u001b[39m \u001b[33mCustomizing Vortex for your project\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠠\u001b[39m \u001b[33mCustomizing Vortex for your project\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠤\u001b[39m \u001b[33mCustomizing Vortex for your project\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠄\u001b[39m \u001b[33mCustomizing Vortex for your project\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠆\u001b[39m \u001b[33mCustomizing Vortex for your project\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠂\u001b[39m \u001b[33mCus"]
-[47.465496, "o", "tomizing Vortex for your project\u001b[39m\r"]
-[47.465583, "o", "\r\r\n"]
-[47.51952, "o", "\u001b[999D\u001b[2A\u001b[J\u001b[?25h"]
-[47.519546, "o", "\r\r\n \u001b[34m✦ Customizing Vortex for your project\u001b[39m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n"]
-[47.519657, "o", "\r\r\n \u001b[32m✓ Vortex was customized for your project\u001b[39m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n\u001b[?25l"]
-[47.523855, "o", "\r\r\n \u001b[36m⠂\u001b[39m \u001b[33mPreparing destination directory\u001b[39m\r\r\n"]
-[47.553284, "o", "\u001b[999D\u001b[2A\u001b[J\u001b[?25h\r\r\n \u001b[34m✦ Preparing destination directory\u001b[39m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n\r\r\n \u001b[2mCreated directory \"/home/user/www/demo/star_wars\".\u001b[22m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n\r\r\n \u001b[2mInitialising a new Git repository in directory \"/home/user/www/demo/star_wars\".\u001b[22m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n\r\r\n \u001b[32m✓ Destination directory is ready\u001b[39m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n"]
-[47.553462, "o", "\u001b[?25l"]
-[47.556207, "o", "\r\r\n \u001b[36m⠂\u001b[39m \u001b[33mCopying files to the destination directory\u001b[39m\r\r\n"]
-[47.639703, "o", "\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠒\u001b[39m \u001b[33mCopying files to the destination directory\u001b[39m\r\r\n"]
-[47.649892, "o", "\u001b[999D\u001b[2A\u001b[J"]
-[47.650167, "o", "\u001b[?25h\r\r\n \u001b[34m✦ Copying files to the destination directory\u001b[39m\r\r\n\r\r\n"]
-[47.650352, "o", "\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n\r\r\n \u001b[32m✓ Files copied to destination directory\u001b[39m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n\u001b[?25l"]
-[47.652852, "o", "\r\r\n \u001b[36m⠂\u001b[39m \u001b[33mPreparing demo content\u001b[39m\r\r\n"]
-[47.74774, "o", "\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠒\u001b[39m \u001b[33mPreparing demo content\u001b[39m\r\r\n"]
-[47.826607, "o", "\u001b[1G\u001b[2A\u001b[J"]
-[47.826719, "o", "\r\r\n \u001b[36m⠐\u001b[39m \u001b[33mPreparing demo content\u001b[39m\r\r\n"]
-[47.906043, "o", "\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠰\u001b[39m \u001b[33mPreparing demo content\u001b[39m\r\r\n"]
-[47.984261, "o", "\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠠\u001b[39m \u001b[33mPreparing demo content\u001b[39m\r\r\n"]
-[48.062969, "o", "\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠤\u001b[39m \u001b[33mPreparing demo content\u001b[39m\r\r\n"]
-[48.154902, "o", "\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠄\u001b[39m \u001b[33mPreparing demo content\u001b[39m\r\r\n"]
-[48.23427, "o", "\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠆\u001b[39m \u001b[33mPreparing demo content\u001b[39m\r\r\n"]
-[48.315394, "o", "\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠂\u001b[39m \u001b[33mPreparing demo content\u001b[39m\r\r\n"]
-[48.393959, "o", "\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠒\u001b[39m \u001b[33mPreparing demo content\u001b[39m\r\r\n"]
-[48.472987, "o", "\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠐\u001b[39m \u001b[33mPreparing demo content\u001b[39m\r\r\n"]
-[48.531191, "o", "\u001b[999D\u001b[2A\u001b[J\u001b[?25h"]
-[48.531344, "o", "\r\r\n \u001b[34m✦ Preparing demo content\u001b[39m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n\r\r\n \u001b[2mCreated data directory \"/home/user/www/demo/star_wars/.data\".\u001b[22m\r\r\n\r\r\n"]
-[48.531482, "o", "\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n\r\r\n \u001b[2mNo database dump file was found in \"/home/user/www/demo/star_wars/.data\" directory.\u001b[22m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n\r\r\n \u001b[2mDownloaded demo database from https://github.com/drevops/vortex/releases/download/25.4.0/db_d11.demo.sql.\u001b[22m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n\r\r\n \u001b[32m✓ Demo content prepared\u001b[39m\r\r\n\r\r\n"]
-[48.531618, "o", "\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n"]
-[48.550699, "o", "\r\r\n \u001b[90m┌──────────────────────────────────────────────────────────────────────────┐\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[32mFinished installing Vortex\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[32m──────────────────────────\u001b[39m\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Next steps:\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Add and commit all files:\u001b[39m\u001b[39m \u001b[39"]
-[48.550826, "o", "m\u001b[90m"]
-[48.550911, "o", "│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m cd /home/user/www/demo/star_wars\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m git add -A\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m git commit -m \"Initial commit.\"\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Build project locally:\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m ahoy build\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Setup integration with your hosting and CI/CD providers:\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m "]
-[48.550937, "o", " See https://www.vortextemplate.com/docs/getting-started/installation\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m└──────────────────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n"]
+{"version": 2, "width": 120, "height": 36, "timestamp": 1764660658, "env": {"SHELL": "/opt/homebrew/opt/bash/bin/bash", "TERM": "xterm-256color"}, "title": "Vortex Installer Demo"}
+[0.841414, "o", "\r\r\n \u001b[36m──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────\u001b[39m\r\r\n \u001b[36m\u001b[39m\r\r\n \u001b[36m\u001b[39m\r\r\n \u001b[36m ██╗ ██╗ ██████╗ ██████╗ ████████╗ ███████╗ ██╗ ██╗\u001b[39m\r\r\n \u001b[36m ██║ ██║ ██╔═══██╗ ██╔══██╗ ╚══██╔══╝ ██╔════╝ ╚██╗██╔╝\u001b[39m\r\r\n \u001b[36m ██║ ██║ ██║ ██║ ██████╔╝ ██║ █████╗ ╚███╔╝\u001b[39m\r\r\n \u001b[36m ╚██╗ ██╔╝ █"]
+[0.841523, "o", "█║ "]
+[0.841588, "o", " ██║ ██╔══██╗ ██║ ██╔══╝ ██╔██╗\u001b[39m\r\r\n \u001b[36m ╚████╔╝ ╚██████╔╝ ██║ ██║ ██║ ███████╗ ██╔╝ ██╗\u001b[39m\r\r\n \u001b[36m ╚═══╝ ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚══════╝ ╚═╝ ╚═╝\u001b[39m\r\r\n \u001b[36m\u001b[39m\r\r\n \u001b[36m Drupal project template\u001b[39m\r\r\n \u001b[36m\u001b[39m\r\r\n \u001b[36m by DrevOps\u001b[39m\r\r\n \u001b[36m\u001b[39m\r\r\n \u001b[36m────────────────────────────────────────────────────────────────────────────────────────────────────────────────"]
+[0.841623, "o", "──────\u001b[39m\r\r\n \u001b[2m Installer version: development\u001b[22m\r\r\n\r\r\n"]
+[0.845958, "o", "\r\r\n \u001b[90m┌──────────────────────────────────────────────────────────────────────────────────────┐\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[32mWelcome to the Vortex interactive installer\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[32m───────────────────────────────────────────\u001b[39m\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m This tool will guide you through installing the latest \u001b[4mstable\u001b[0m version of Vortex into\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m your project.\u001b[39m\u001b[39m "]
+[0.846039, "o", " \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m You will be asked a few questions to tailor the configuration to your site.\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m No changes will be made until you confirm everything at the end.\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Press \u001b[33mCtrl+C\u001b[39m at any time to exit the installer.\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Press \u001b[33mCtrl+U\u001b[39m at any time to go back to the previous step.\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m└───────"]
+[0.846072, "o", "─────"]
+[0.846152, "o", "──────────────────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n \u001b[2mPress any key to continue...\u001b[22m\r\r\n"]
+[3.866037, "o", "\r\r\n \u001b[46m\u001b[30m General information \u001b[39m\u001b[49m\r\r\n\r\r\n"]
+[3.882799, "o", "\u001b[?25l"]
+[3.888159, "o", "\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mSite name \u001b[2m(1/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m star wars\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name in the project and documentation.\u001b[39m\r\r\n"]
+[4.043486, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mSite name \u001b[2m(1/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m star wa\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name in the project and documentation.\u001b[39m\r\r\n\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mSite name \u001b[2m(1/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m\u001b[7mE\u001b[27m.g. My Site\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────"]
+[4.043636, "o", "──"]
+[4.781206, "o", "──────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name in the project and documentation.\u001b[39m\r\r\n\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mSite name \u001b[2m(1/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m S\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name in the project and documentation.\u001b[39m\r\r\n\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mSite name \u001b[2m(1/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m St\u001b[7m \u001b[27m "]
+[4.781657, "o", " "]
+[4.782813, "o", " \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name in the project and documentation.\u001b[39m\r\r\n"]
+[5.784456, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mSite name \u001b[2m(1/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Star Wars\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name in the project and documentation.\u001b[39m\r\r\n"]
+[5.798878, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mSite name \u001b[2m(1/29)\u001b[22m\u001b[22m \u001b[90m────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Star Wars \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n"]
+[5.799051, "o", "\u001b[?25h"]
+[5.82485, "o", "\u001b[?25l"]
+[5.828239, "o", "\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mSite machine name \u001b[2m(2/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m star_wars\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name for the project directory and in the code.\u001b[39m\r\r\n"]
+[6.852015, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mSite machine name \u001b[2m(2/29)\u001b[22m\u001b[22m \u001b[90m────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m star_wars \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h"]
+[6.875763, "o", "\u001b[?25l"]
+[6.87863, "o", "\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mOrganization name \u001b[2m(3/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Star Wars Org\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name in the project and documentation.\u001b[39m\r\r\n"]
+[7.02972, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mOrganization name \u001b[2m(3/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Star War\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name in the project and documentation.\u001b[39m\r\r\n\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mOrganization name \u001b[2m(3/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m\u001b[7mE\u001b[27m.g. My Org\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └────────────────────────────────────────────"]
+[7.029966, "o", "───"]
+[8.315377, "o", "───────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name in the project and documentation.\u001b[39m\r\r\n\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mOrganization name \u001b[2m(3/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m R\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name in the project and documentation.\u001b[39m\r\r\n\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mOrganization name \u001b[2m(3/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Re\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └─────"]
+[8.315666, "o", "────"]
+[8.316008, "o", "─────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name in the project and documentation.\u001b[39m\r\r\n"]
+[9.317732, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mOrganization name \u001b[2m(3/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Rebellion\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name in the project and documentation.\u001b[39m\r\r\n"]
+[10.321878, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mOrganization name \u001b[2m(3/29)\u001b[22m\u001b[22m \u001b[90m────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Rebellion \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mOrganization machine name \u001b[2m(4/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m rebellion\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name in the co"]
+[10.322255, "o", "de.\u001b[39m\r"]
+[10.322793, "o", "\r\r\n"]
+[11.327259, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mOrganization machine name \u001b[2m(4/29)\u001b[22m\u001b[22m \u001b[90m────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m rebellion \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mPublic domain \u001b[2m(5/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m star-wars.com\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Domain name without pro"]
+[11.329369, "o", "tocol and"]
+[11.332374, "o", " trailing slash.\u001b[39m\r\r\n"]
+[12.339811, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mPublic domain \u001b[2m(5/29)\u001b[22m\u001b[22m \u001b[90m────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m star-wars.com \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\r\r\n \u001b[46m\u001b[30m Drupal \u001b[39m\u001b[49m\r\r\n\r\r\n\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mHow would you like your site to be created on the first run? \u001b[2m(6/29)\u001b[22m\u001b[39m \u001b[90m┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Choose how your site will be created the first time after this \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m installer finishes: \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m "]
+[12.340081, "o", " "]
+[12.342523, "o", " \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mDrupal, installed from profile\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Creates a new site by \u001b[4mpopulating a fresh database\u001b[0m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m from one of the standard Drupal installation profiles. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mDrupal CMS, installed from profile\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Creates a new site by \u001b[4mpopulating a fresh database\u001b[0m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m from the Drupal CMS recipe. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mDrupal, loaded from the demo database\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Creates a site by \u001b[4mloadin"]
+[12.342741, "o", "g an exist"]
+[13.346275, "o", "ing demo database\u001b[0m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m provided with the installer. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m ├─────────────────────────────────────────────────────────────────────┤\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mDrupal, installed from profile\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mDrupal CMS, installed from profile\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m›\u001b[39m \u001b[36m●\u001b[39m Drupal, loaded from the demo database \u001b[90m│\u001b[39m\r\r\n\u001b[90m └────────────────────────────────────────────────────────────"]
+[13.347143, "o", "──"]
+[13.347242, "o", "───────┘\u001b[39m\r\r\n\u001b[90m Use ⬆ and ⬇. Applies only on the first run of the installer.\u001b[39m\r\r\n"]
+[14.37127, "o", "\u001b[1G\u001b[24A\u001b[J"]
+[14.371501, "o", "\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mHow would you like your site to be created on the first run? \u001b[2m(6/29)\u001b[22m\u001b[22m \u001b[90m┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Choose how your site will be created the first time after this \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m installer finishes: \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mDrupal, installed from profile\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Creates a new site by \u001b[4mpopulating a fresh database\u001b[0m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m from one of the standard Drupal installation profiles. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mDrupal CMS, installed from profile\u001b[22m "]
+[14.371523, "o", " \u001b[90m│\u001b"]
+[14.371948, "o", "[39m\r\r\n\u001b[90m │\u001b[39m Creates a new site by \u001b[4mpopulating a fresh database\u001b[0m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m from the Drupal CMS recipe. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mDrupal, loaded from the demo database\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Creates a site by \u001b[4mloading an existing demo database\u001b[0m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m provided with the installer. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m ├─────────────────────────────────────────────────────────────────────┤\u001b[39m\r\r\n\u001b[90m │\u001b[39m Drupal, loaded from the demo database "]
+[14.372456, "o", " \u001b[90m│\u001b[39m\r\r\n\u001b[90m └─────────────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h"]
+[15.379188, "o", "\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mProfile \u001b[2m(7/29)\u001b[22m\u001b[39m \u001b[90m──────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m›\u001b[39m \u001b[36m●\u001b[39m Standard \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mMinimal\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mDemo Umami\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mCustom (next prompt)\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆ and ⬇ to select which Drupal profile to use.\u001b[39m\r\r\n"]
+[16.387369, "o", "\u001b[1G\u001b[8A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mProfile \u001b[2m(7/29)\u001b[22m\u001b[22m \u001b[90m──────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Standard \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mModules \u001b[2m(8/29)\u001b[22m\u001b[39m \u001b[90m──────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m› ◼\u001b[39m Admin toolbar \u001b[36m┃\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mCoffee\u001b[22m \u001b[90m│\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mConfig spli"]
+[16.387932, "o", "t\u001b[22m "]
+[16.389386, "o", " \u001b[90m│\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mConfig update\u001b[22m \u001b[90m│\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mEnvironment indicator\u001b[22m \u001b[90m│\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mPathauto\u001b[22m \u001b[90m│\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mRedirect\u001b[22m \u001b[90m│\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mRobots.txt\u001b[22m \u001b[90m│\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mSeckit\u001b[22m \u001b[90m│\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mShield\u001b[22m \u001b[90m│\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └───────────────────────"]
+[16.389471, "o", "───"]
+[17.394244, "o", "─────────────────────── 11 selected ┘\u001b[39m\r\r\n\u001b[90m Use ⬆, ⬇ and Space bar to select one or more modules.\u001b[39m\r\r\n\u001b[1G\u001b[14A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mModules \u001b[2m(8/29)\u001b[22m\u001b[22m \u001b[90m──────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Admin toolbar \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Coffee \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Config split \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Config update \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Environment indicator \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Pathauto \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Redirect \u001b[90m│\u001b[39m\r"]
+[17.394667, "o", "\r\n\u001b[90m │"]
+[17.39499, "o", "\u001b[39m Robots.txt \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Seckit \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Shield \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Stage file proxy \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h"]
+[18.399481, "o", "\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mCustom modules prefix \u001b[2m(9/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m sw\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name in custom modules\u001b[39m\r\r\n\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mCustom modules prefix \u001b[2m(9/29)\u001b[22m\u001b[22m \u001b[90m────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m sw \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b"]
+[18.399738, "o", "[39m\r\r\n\r\r\n"]
+[18.400256, "o", "\u001b[?25h"]
+[19.404382, "o", "\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mTheme \u001b[2m(10/29)\u001b[22m\u001b[39m \u001b[90m───────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mOlivero\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mClaro\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mStark\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m›\u001b[39m \u001b[36m●\u001b[39m Custom (next prompt) \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆ and ⬇ to select which Drupal theme to use.\u001b[39m\r\r\n\u001b[1G\u001b[8A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mTheme \u001b[2m(10/29)\u001b[22m\u001b[22m \u001b[90m──────"]
+[19.404554, "o", "───"]
+[19.404587, "o", "──────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Custom (next prompt) \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h"]
+[20.407938, "o", "\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mCustom theme machine name \u001b[2m(11/29)\u001b[22m\u001b[39m \u001b[90m───────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m star_wars\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m We will use this name as a custom theme name\u001b[39m\r\r\n\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mCustom theme machine name \u001b[2m(11/29)\u001b[22m\u001b[22m \u001b[90m───────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m star_wars \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25"]
+[20.408101, "o", "h\r\r\n \u001b[46m\u001b"]
+[20.40824, "o", "[30m Code repository \u001b[39m\u001b[49m\r\r\n\r\r\n"]
+[21.41223, "o", "\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mRepository provider \u001b[2m(12/29)\u001b[22m\u001b[39m \u001b[90m─────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Vortex offers full automation with GitHub, while support for \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m other providers is limited. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m ├──────────────────────────────────────────────────────────────┤\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m›\u001b[39m \u001b[36m●\u001b[39m GitHub \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mOther\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └─────────"]
+[21.412544, "o", "───"]
+[21.413356, "o", "──────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆ and ⬇ to select your code repository provider.\u001b[39m\r\r\n"]
+[22.420813, "o", "\u001b[1G\u001b[11A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mRepository provider \u001b[2m(12/29)\u001b[22m\u001b[22m \u001b[90m─────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Vortex offers full automation with GitHub, while support for \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m other providers is limited. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m ├──────────────────────────────────────────────────────────────┤\u001b[39m\r\r\n\u001b[90m │\u001b[39m GitHub \u001b[90m│\u001b[39m\r\r\n\u001b[90m └────────────────────────────────────────────────────"]
+[22.420988, "o", "───"]
+[22.421024, "o", "───────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h"]
+[23.422723, "o", "\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mRelease versioning scheme \u001b[2m(13/29)\u001b[22m\u001b[39m \u001b[90m───────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Choose your versioning scheme: \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mCalendar Versioning (CalVer)\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[4myear.month.patch\u001b[0m (E.g., \u001b[4m24.1.0\u001b[0m) \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m https://calver.org \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mSemantic Versioning (SemVer)\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[4mmajor.minor.patch\u001b[0m (E.g., \u001b[4m1.0.0\u001b[0m) "]
+[23.422868, "o", " \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m https://semver.org \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mOther\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Custom versioning scheme of your choice. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m ├──────────────────────────────────────────────────────────────┤\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m›\u001b[39m \u001b[36m●\u001b[39m Calendar Versioning (CalVer) \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mSemantic Versioning (SemVer)\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mOther\u001b[22m "]
+[23.423282, "o", " \u001b[90m│\u001b["]
+[23.423552, "o", "39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆ and ⬇ to select your version scheme.\u001b[39m\r\r\n"]
+[24.4358, "o", "\u001b[1G\u001b[22A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mRelease versioning scheme \u001b[2m(13/29)\u001b[22m\u001b[22m \u001b[90m───────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Choose your versioning scheme: \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mCalendar Versioning (CalVer)\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[4myear.month.patch\u001b[0m (E.g., \u001b[4m24.1.0\u001b[0m) \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m https://calver.org \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mSemantic Versioning (SemVer)\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[4mmajor.minor.patch\u001b[0m (E.g., \u001b[4m1.0.0\u001b[0m) "]
+[24.436106, "o", " \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m https://semver.org \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mOther\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Custom versioning scheme of your choice. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m ├──────────────────────────────────────────────────────────────┤\u001b[39m\r\r\n\u001b[90m │\u001b[39m Calendar Versioning (CalVer) \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h"]
+[25.437616, "o", "\r\r\n \u001b[46m\u001b[30m Environment \u001b[39m\u001b[49m\r\r\n\r\r\n\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mTimezone \u001b[2m(14/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m UTC\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m ├──────────────────────────────────────────────────────────────┤\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2mUTC\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆ and ⬇, or start typing to select the timezone for your project.\u001b[39m\r\r\n\u001b[1G\u001b[7A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mTimezone \u001b[2m(14/29)\u001b[22m\u001b[22m \u001b[90m"]
+[25.437968, "o", "────"]
+[25.438385, "o", "────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m UTC \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h"]
+[26.442409, "o", "\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mServices \u001b[2m(15/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m› ◼\u001b[39m ClamAV \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mSolr\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mRedis\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆, ⬇ and Space bar to select one or more services.\u001b[39m\r\r\n\u001b[1G\u001b[7A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mServices \u001b[2m(15/29)\u001b[22m\u001b[22m \u001b[90m────────────────────────────────────────────┐\u001b"]
+[26.444439, "o", "[39m\r\r\n\u001b["]
+[26.44603, "o", "90m │\u001b[39m ClamAV \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Solr \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Redis \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h"]
+[27.447981, "o", "\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mDevelopment tools \u001b[2m(16/29)\u001b[22m\u001b[39m \u001b[90m───────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m› ◼\u001b[39m PHP CodeSniffer \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mPHPStan\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mRector\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mPHP Mess Detector\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mPHPUnit\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mBehat\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └───────────────────────────────────────────────────"]
+[27.448246, "o", "───"]
+[27.448931, "o", "────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆, ⬇ and Space bar to select one or more tools.\u001b[39m\r\r\n"]
+[28.46156, "o", "\u001b[1G\u001b[10A\u001b[J"]
+[28.461693, "o", "\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mDevelopment tools \u001b[2m(16/29)\u001b[22m\u001b[22m \u001b[90m───────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m PHP CodeSniffer \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m PHPStan \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Rector \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m PHP Mess Detector \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m PHPUnit \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Behat \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h"]
+[29.465223, "o", "\r\r\n \u001b[46m\u001b[30m Hosting \u001b[39m\u001b[49m\r\r\n\r\r\n\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mHosting provider \u001b[2m(17/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mAcquia Cloud\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mLagoon\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mOther\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m›\u001b[39m \u001b[36m●\u001b[39m None \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆, ⬇ and Space bar to select your hosting provider.\u001b[39m\r\r\n"]
+[30.466712, "o", "\u001b[1G\u001b[8A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mHosting provider \u001b[2m(17/29)\u001b[22m\u001b[22m \u001b[90m────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m None \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mCustom web root directory \u001b[2m(18/29)\u001b[22m\u001b[39m \u001b[90m───────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m web\u001b[7m \u001b[27m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Custom directory where the web se"]
+[30.466868, "o", "rver serv"]
+[30.46698, "o", "es the site.\u001b[39m\r\r\n"]
+[31.472441, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mCustom web root directory \u001b[2m(18/29)\u001b[22m\u001b[22m \u001b[90m───────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m web \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\r\r\n \u001b[46m\u001b[30m Deployment \u001b[39m\u001b[49m\r\r\n\r\r\n\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mDeployment types \u001b[2m(19/29)\u001b[22m\u001b[39m \u001b[90m────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m›\u001b[39m ◻ Code artifact \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m◻\u001b[22m \u001b[2mLagoon webhook\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m◻\u001b[22m \u001b[2mContainer image\u001b[22m "]
+[31.472754, "o", " \u001b[9"]
+[31.473081, "o", "0m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m◼\u001b[39m \u001b[2mCustom webhook\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆, ⬇ and Space bar to select one or more deployment types.\u001b[39m\r\r\n"]
+[32.477362, "o", "\u001b[1G\u001b[8A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mDeployment types \u001b[2m(19/29)\u001b[22m\u001b[22m \u001b[90m────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Custom webhook \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\r\r\n \u001b[46m\u001b[30m Workflow \u001b[39m\u001b[49m\r\r\n\r\r\n\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mProvision type \u001b[2m(20/29)\u001b[22m\u001b[39m \u001b[90m──────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Provisioning sets up the site in an environment using an \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m already assembled codebase. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │"]
+[32.477988, "o", "\u001b[39m "]
+[32.480754, "o", " \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mImport from database dump\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Provisions the site by importing a database dump \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m typically copied from production into lower \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m environments. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mInstall from profile\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Provisions the site by installing a fresh Drupal \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m site from a profile every time an environment is \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m created. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m ├─"]
+[32.480971, "o", "───"]
+[33.483389, "o", "──────────────────────────────────────────────────────────┤\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m›\u001b[39m \u001b[36m●\u001b[39m Import from database dump \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mInstall from profile\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆ and ⬇ to select the provision type.\u001b[39m\r\r\n\u001b[1G\u001b[21A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mProvision type \u001b[2m(20/29)\u001b[22m\u001b[22m \u001b[90m──────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Provisioning sets up the site in an e"]
+[33.483595, "o", "nvironment"]
+[33.483911, "o", " using an \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m already assembled codebase. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mImport from database dump\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Provisions the site by importing a database dump \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m typically copied from production into lower \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m environments. \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m ○ \u001b[1mInstall from profile\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m Provisions the site by installing a fresh Drupal \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m site from a profile every time an environment is \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m created. "]
+[33.484278, "o", " "]
+[34.487757, "o", " \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[90m│\u001b[39m\r\r\n\u001b[90m ├──────────────────────────────────────────────────────────────┤\u001b[39m\r\r\n\u001b[90m │\u001b[39m Import from database dump \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mDatabase source \u001b[2m(21/29)\u001b[22m\u001b[39m \u001b[90m─────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m›\u001b[39m \u001b[36m●\u001b[39m URL download \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mFTP download\u001b[22m "]
+[34.487863, "o", " "]
+[34.487923, "o", " \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mAcquia backup\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mLagoon environment\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mContainer registry\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mNone\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆ and ⬇ to select the database download source.\u001b[39m\r\r\n"]
+[35.508326, "o", "\u001b[1G\u001b[10A\u001b[J"]
+[35.508471, "o", "\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mDatabase source \u001b[2m(21/29)\u001b[22m\u001b[22m \u001b[90m─────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m URL download \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h"]
+[36.51256, "o", "\r\r\n \u001b[46m\u001b[30m Notifications \u001b[39m\u001b[49m\r\r\n\r\r\n\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mNotification channels \u001b[2m(22/29)\u001b[22m\u001b[39m \u001b[90m───────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m› ◼\u001b[39m Email \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m◻\u001b[22m \u001b[2mGitHub\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m◻\u001b[22m \u001b[2mJIRA\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m◻\u001b[22m \u001b[2mNew Relic\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m◻\u001b[22m \u001b[2mSlack\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m◻\u001b[22m \u001b[2mWebhook\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └────────────────────────────────────────"]
+[36.513313, "o", "────"]
+[36.513344, "o", "──────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆, ⬇ and Space bar to select one or more notification channels.\u001b[39m\r\r\n"]
+[37.51677, "o", "\u001b[1G\u001b[10A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mNotification channels \u001b[2m(22/29)\u001b[22m\u001b[22m \u001b[90m───────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Email \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\r\r\n \u001b[46m\u001b[30m Continuous Integration \u001b[39m\u001b[49m\r\r\n\r\r\n\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mContinuous Integration provider \u001b[2m(23/29)\u001b[22m\u001b[39m \u001b[90m─────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m›\u001b[39m \u001b[36m●\u001b[39m GitHub Actions \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mCircleCI\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mNone\u001b[22m "]
+[37.517051, "o", " \u001b["]
+[37.517452, "o", "90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆ and ⬇ to select the CI provider.\u001b[39m\r\r\n"]
+[38.522235, "o", "\u001b[1G\u001b[7A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mContinuous Integration provider \u001b[2m(23/29)\u001b[22m\u001b[22m \u001b[90m─────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m GitHub Actions \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\r\r\n \u001b[46m\u001b[30m Automations \u001b[39m\u001b[49m\r\r\n\r\r\n\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mDependency updates provider \u001b[2m(24/29)\u001b[22m\u001b[39m \u001b[90m─────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m›\u001b[39m \u001b[36m●\u001b[39m Renovate GitHub app \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mRenovate self-hosted in CI\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mNone\u001b[22m \u001b[90m│\u001b[39m\r"]
+[38.522565, "o", "\r\n\u001b[90m └"]
+[38.523624, "o", "──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Use ⬆ and ⬇ to select the dependency updates provider.\u001b[39m\r\r\n"]
+[39.52549, "o", "\u001b[1G\u001b[7A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mDependency updates provider \u001b[2m(24/29)\u001b[22m\u001b[22m \u001b[90m─────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Renovate GitHub app \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mAuto-assign the author to their PR? \u001b[2m(25/29)\u001b[22m\u001b[39m \u001b[90m─────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[32m●\u001b[39m Yes \u001b[2m/ ○ No\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Helps to keep the PRs organized.\u001b[39m\r\r\n"]
+[40.530026, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mAuto-assign the author to their PR? \u001b[2m(25/29)\u001b[22m\u001b[22m \u001b[90m─────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Yes \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mAuto-add a CONFLICT label to a PR when conflicts occur? \u001b[2m(26/29)\u001b[22m\u001b[39m \u001b[90m┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[32m●\u001b[39m Yes \u001b[2m/ ○ No\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └─────────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Helps to keep quickly identify PRs that need attention.\u001b[39m\r\r\n"]
+[41.531194, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mAuto-add a CONFLICT label to a PR when conflicts occur? \u001b[2m(26/29)\u001b[22m\u001b[22m \u001b[90m┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Yes \u001b[90m│\u001b[39m\r\r\n\u001b[90m └─────────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\r\r\n \u001b[46m\u001b[30m Documentation \u001b[39m\u001b[49m\r\r\n\r\r\n\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mPreserve project documentation? \u001b[2m(27/29)\u001b[22m\u001b[39m \u001b[90m─────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[32m●\u001b[39m Yes \u001b[2m/ ○ No\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Helps to maintain the project documentation"]
+[41.531377, "o", " within the "]
+[41.5314, "o", "repository.\u001b[39m\r\r\n"]
+[42.533393, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mPreserve project documentation? \u001b[2m(27/29)\u001b[22m\u001b[22m \u001b[90m─────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Yes \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\r\r\n \u001b[46m\u001b[30m AI \u001b[39m\u001b[49m\r\r\n\r\r\n\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mAI code assistant instructions \u001b[2m(28/29)\u001b[22m\u001b[39m \u001b[90m──────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[36m›\u001b[39m \u001b[36m●\u001b[39m Anthropic Claude \u001b[90m│\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○\u001b[22m \u001b[2mNone\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────"]
+[42.533615, "o", "────"]
+[42.533763, "o", "────────────────────┘\u001b[39m\r\r\n\u001b[90m Provides AI coding assistants with better context about the project.\u001b[39m\r\r\n"]
+[43.538126, "o", "\u001b[1G\u001b[6A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mAI code assistant instructions \u001b[2m(28/29)\u001b[22m\u001b[22m \u001b[90m──────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Anthropic Claude \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\r\r\n \u001b[46m\u001b[30m \u001b[39m\u001b[49m\r\r\n \u001b[46m\u001b[30m Installation summary \u001b[39m\u001b[49m\r\r\n \u001b[46m\u001b[30m \u001b[39m\u001b[49m\r\r\n\r\r\n\r\r\n \u001b[90m┌────────────────────────────────────┬─────────────────────────────────────────┐\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[36m\u001b[1mGeneral information\u001b[22m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m"]
+[43.538638, "o", "│\u001b[39m\r\r\n \u001b"]
+[43.540167, "o", "[90m│\u001b[39m\u001b[39m Site name \u001b[39m\u001b[90m│\u001b[39m\u001b[39m Star Wars \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Site machine name \u001b[39m\u001b[90m│\u001b[39m\u001b[39m star_wars \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Organization name \u001b[39m\u001b[90m│\u001b[39m\u001b[39m Rebellion \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Organization machine name \u001b[39m\u001b[90m│\u001b[39m\u001b[39m rebellion \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Public domain \u001b[39m\u001b[90m│\u001b[39m\u001b[39m star-wars.com \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[36m\u001b[1mDrupal\u001b[22m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Starter \u001b[39m\u001b[90m│\u001b[39m\u001b[39m load_demodb \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Modul"]
+[43.541217, "o", "es "]
+[44.541043, "o", " \u001b[39m\u001b[90m│\u001b[39m\u001b[39m admin_toolbar, coffee, config_split,\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m config_update, environment_indicator,\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m pathauto, redirect, robotstxt, seckit,\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m shield, stage_file_proxy \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Webroot \u001b[39m\u001b[90m│\u001b[39m\u001b[39m web \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Profile \u001b[39m\u001b[90m│\u001b[39m\u001b[39m standard \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Module prefix \u001b[39m\u001b[90m│\u001b[39m\u001b[39m sw \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Theme machine name "]
+[44.541148, "o", " "]
+[44.541321, "o", "\u001b[39m\u001b[90m│\u001b[39m\u001b[39m star_wars \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[36m\u001b[1mCode repository\u001b[22m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Code provider \u001b[39m\u001b[90m│\u001b[39m\u001b[39m github \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Version scheme \u001b[39m\u001b[90m│\u001b[39m\u001b[39m calver \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[36m\u001b[1mEnvironment\u001b[22m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Timezone \u001b[39m\u001b[90m│\u001b[39m\u001b[39m UTC \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Services \u001b[39m\u001b[90m│\u001b[39m\u001b[39m clamav, redis, solr \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Tools \u001b[39m\u001b"]
+[44.541391, "o", "[90m│\u001b[39m\u001b[39m phpcs, phpmd, phpstan, rector, phpunit,\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m behat \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[36m\u001b[1mHosting\u001b[22m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Hosting provider \u001b[39m\u001b[90m│\u001b[39m\u001b[39m none \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[36m\u001b[1mDeployment\u001b[22m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Deployment types \u001b[39m\u001b[90m│\u001b[39m\u001b[39m webhook \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[36m\u001b[1mWorkflow\u001b[22m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Provision type "]
+[44.541471, "o", " \u001b[39m\u001b[90m│\u001b[39m\u001b[39m database \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Database source \u001b[39m\u001b[90m│\u001b[39m\u001b[39m url \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[36m\u001b[1mNotifications\u001b[22m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Channels \u001b[39m\u001b[90m│\u001b[39m\u001b[39m email \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[36m\u001b[1mContinuous Integration\u001b[22m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m CI provider \u001b[39m\u001b[90m│\u001b[39m\u001b[39m gha \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[36m\u001b[1mAutomations\u001b[22m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Dep"]
+[44.541504, "o", "endency updates provi"]
+[44.541598, "o", "der \u001b[39m\u001b[90m│\u001b[39m\u001b[39m renovatebot_app \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Auto-assign PR author \u001b[39m\u001b[90m│\u001b[39m\u001b[39m Yes \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Auto-add a CONFLICT label to PRs \u001b[39m\u001b[90m│\u001b[39m\u001b[39m Yes \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[36m\u001b[1mDocumentation\u001b[22m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Preserve project documentation \u001b[39m\u001b[90m│\u001b[39m\u001b[39m Yes \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[36m\u001b[1mAI\u001b[22m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m AI code assistant instructions \u001b[39m\u001b[90m│\u001b[39m\u001b[39m claude \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[36m\u001b[1mLocations\u001b[22m\u001b[39m "]
+[44.541648, "o", " "]
+[44.541748, "o", " \u001b[39m\u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Current directory \u001b[39m\u001b[90m│\u001b[39m\u001b[39m /home/user/www/demo \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Destination directory \u001b[39m\u001b[90m│\u001b[39m\u001b[39m /home/user/www/demo/star_wars \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Vortex repository \u001b[39m\u001b[90m│\u001b[39m\u001b[39m https://github.com/drevops/vortex.git \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Vortex reference \u001b[39m\u001b[90m│\u001b[39m\u001b[39m stable \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m└────────────────────────────────────┴─────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n Vortex will be installed into your project's directory \"/home/user/www/demo/star_wars\"\r\r\n"]
+[46.54542, "o", "\u001b[?25l\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mProceed with installing Vortex?\u001b[39m \u001b[90m─────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[32m●\u001b[39m Yes \u001b[2m/ ○ No\u001b[22m \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n"]
+[48.547474, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mProceed with installing Vortex?\u001b[22m \u001b[90m─────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m Yes \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h\r\r\n \u001b[46m\u001b[30m Starting project installation \u001b[39m\u001b[49m\r\r\n\r\r\n\u001b[?25l\r\r\n \u001b[36m⠂\u001b[39m \u001b[33mDownloading Vortex\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠒\u001b[39m \u001b[33mDownloading Vortex\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠐\u001b[39m \u001b[33mDownloading Vortex\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠰\u001b[39m \u001b[33mDownloading Vortex\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠠\u001b[39m \u001b[33mDownloading Vortex\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠤\u001b[39m \u001b[33mDownloading Vortex\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠄\u001b[39m \u001b[33mDownloading Vortex\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠆\u001b[39m \u001b[33mDownloading Vortex\u001b[39m\r\r\n\u001b[1G\u001b[2A"]
+[48.552249, "o", "\u001b[J\r\r\n \u001b[36m⠂\u001b[39m \u001b[33"]
+[49.55125, "o", "mDownloading Vortex\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠒\u001b[39m \u001b[33mDownloading Vortex\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠐\u001b[39m \u001b[33mDownloading Vortex\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠰\u001b[39m \u001b[33mDownloading Vortex\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠠\u001b[39m \u001b[33mDownloading Vortex\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠤\u001b[39m \u001b[33mDownloading Vortex\u001b[39m\r\r\n\u001b[999D\u001b[2A\u001b[J\u001b[?25h\r\r\n \u001b[34m✦ Downloading Vortex\u001b[39m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n\r\r\n \u001b[2mDownloading from \"https://github.com/drevops/vortex.git\" repository at commit \"HEAD\"\u001b[22m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n\r\r\n \u001b[32m✓ Vortex downloaded (25.10.0)\u001b[39m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n\u001b[?25l\r\r\n \u001b[36m⠂\u001b[39m \u001b[33mCustomizing Vortex for your project\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠒\u001b[39m \u001b[33mCustomizing Vortex for your project\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠐\u001b[39m \u001b[33mCustomizing Vortex for your project\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠰\u001b[39m \u001b[33mCustomizing Vortex for your project\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠠\u001b[39m \u001b[33mCustomizing Vortex for your project\u001b[39m\r\r\n\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠤\u001b[39m"]
+[49.55139, "o", " \u001b[33mCustomizing Vortex for your projec"]
+[49.551457, "o", "t\u001b[39m\r\r\n"]
+[49.630872, "o", "\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠄\u001b[39m \u001b[33mCustomizing Vortex for your project\u001b[39m\r\r\n"]
+[49.710724, "o", "\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠆\u001b[39m \u001b[33mCustomizing Vortex for your project\u001b[39m\r\r\n"]
+[49.789418, "o", "\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠂\u001b[39m \u001b[33mCustomizing Vortex for your project\u001b[39m\r\r\n"]
+[49.867882, "o", "\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠒\u001b[39m \u001b[33mCustomizing Vortex for your project\u001b[39m\r\r\n"]
+[49.945315, "o", "\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠐\u001b[39m \u001b[33mCustomizing Vortex for your project\u001b[39m\r\r\n"]
+[50.02445, "o", "\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠰\u001b[39m \u001b[33mCustomizing Vortex for your project\u001b[39m\r\r\n"]
+[50.102266, "o", "\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠠\u001b[39m \u001b[33mCustomizing Vortex for your project\u001b[39m\r\r\n"]
+[50.181219, "o", "\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠤\u001b[39m \u001b[33mCustomizing Vortex for your project\u001b[39m\r\r\n"]
+[50.259517, "o", "\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠄\u001b[39m \u001b[33mCustomizing Vortex for your project\u001b[39m\r\r\n"]
+[50.33792, "o", "\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠆\u001b[39m \u001b[33mCustomizing Vortex for your project\u001b[39m\r\r\n"]
+[50.4053, "o", "\u001b[999D\u001b[2A\u001b[J\u001b[?25h"]
+[50.405412, "o", "\r\r\n \u001b[34m✦ Customizing Vortex for your project\u001b[39m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n\r\r\n \u001b[32m✓ Vortex was customized for your project\u001b[39m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n"]
+[50.405481, "o", "\u001b[?25l"]
+[50.407836, "o", "\r\r\n \u001b[36m⠂\u001b[39m \u001b[33mPreparing destination directory\u001b[39m\r\r\n"]
+[50.43339, "o", "\u001b[999D\u001b[2A\u001b[J"]
+[50.433505, "o", "\u001b[?25h\r\r\n \u001b[34m✦ Preparing destination directory\u001b[39m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n"]
+[50.433562, "o", "\r\r\n \u001b[2mCreated directory \"/home/user/www/demo/star_wars\".\u001b[22m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n\r\r\n \u001b[2mInitialising a new Git repository in directory \"/home/user/www/demo/star_wars\".\u001b[22m\r\r\n\r\r\n"]
+[50.433636, "o", "\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n"]
+[50.433705, "o", "\r\r\n \u001b[32m✓ Destination directory is ready\u001b[39m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n\u001b[?25l"]
+[50.436018, "o", "\r\r\n \u001b[36m⠂\u001b[39m \u001b[33mCopying files to the destination directory\u001b[39m\r\r\n"]
+[50.544688, "o", "\u001b[999D\u001b[2A\u001b[J\u001b[?25h"]
+[50.544783, "o", "\r\r\n \u001b[34m✦ Copying files to the destination directory\u001b[39m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n"]
+[50.544828, "o", "\r\r\n \u001b[32m✓ Files copied to destination directory\u001b[39m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n\u001b[?25l"]
+[50.547326, "o", "\r\r\n \u001b[36m⠂\u001b[39m \u001b[33mPreparing demo content\u001b[39m\r\r\n"]
+[50.703662, "o", "\u001b[1G\u001b[2A\u001b[J"]
+[50.70377, "o", "\r\r\n \u001b[36m⠒\u001b[39m \u001b[33mPreparing demo content\u001b[39m\r\r\n"]
+[50.782612, "o", "\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠐\u001b[39m \u001b[33mPreparing demo content\u001b[39m\r\r\n"]
+[50.874655, "o", "\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠰\u001b[39m \u001b[33mPreparing demo content\u001b[39m\r\r\n"]
+[50.985615, "o", "\u001b[1G\u001b[2A\u001b[J\r\r\n \u001b[36m⠠\u001b[39m \u001b[33mPreparing demo content\u001b[39m\r\r\n"]
+[51.050122, "o", "\u001b[999D\u001b[2A\u001b[J"]
+[51.050317, "o", "\u001b[?25h"]
+[51.050524, "o", "\r\r\n \u001b[34m✦ Preparing demo content\u001b[39m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n\r\r\n \u001b[2mCreated data directory \"/home/user/www/demo/star_wars/.data\".\u001b[22m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n"]
+[51.050747, "o", "\r\r\n \u001b[2mNo database dump file was found in \"/home/user/www/demo/star_wars/.data\" directory.\u001b[22m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n\r\r\n \u001b[2mDownloaded demo database from https://github.com/drevops/vortex/releases/download/25.4.0/db_d11.demo.sql.\u001b[22m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n"]
+[51.050937, "o", "\r\r\n \u001b[32m✓ Demo content prepared\u001b[39m\r\r\n\r\r\n\r\r\n \u001b[A\u001b[A\u001b[A\u001b[A\r\r\n\r\r\n"]
+[51.073816, "o", "\r\r\n \u001b[90m┌───────────────────────────────────┐\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[32mFinished installing Vortex\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[32m──────────────────────────\u001b[39m\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Add and commit all files:\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m git add -A\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m git commit -m \"Initial commit.\"\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m└───────────────────────────────────┘\u001b[39m\r\r\n\r\r\n"]
+[51.091305, "o", "\u001b[?25l"]
+[51.093791, "o", "\r\r\n\u001b[90m ┌\u001b[39m \u001b[36mRun the site build now?\u001b[39m \u001b[90m─────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m \u001b[2m○ Yes /\u001b[22m \u001b[32m●\u001b[39m No \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\u001b[90m Takes ~5-10 min; output will be streamed. You can skip and run later with: ahoy build\u001b[39m\r\r\n"]
+[53.112736, "o", "\u001b[1G\u001b[5A\u001b[J\r\r\n\u001b[90m ┌\u001b[39m \u001b[2mRun the site build now?\u001b[22m \u001b[90m─────────────────────────────────────┐\u001b[39m\r\r\n\u001b[90m │\u001b[39m No \u001b[90m│\u001b[39m\r\r\n\u001b[90m └──────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n\u001b[?25h"]
+[53.125829, "o", "\r\r\n \u001b[90m┌────────────────────────────────────────────────────────────────────────────────────────┐\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[32mReady to build\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[32m──────────────\u001b[39m\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Build the site:\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m ahoy build\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m"]
+[53.125952, "o", " \u001b[39m\u001b"]
+[53.125992, "o", "[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m Setup GitHub Actions:\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m https://www.vortextemplate.com/docs/continuous-integration/github-actions#onboarding\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m│\u001b[39m\u001b[39m \u001b[39m\u001b[90m│\u001b[39m\r\r\n \u001b[90m└────────────────────────────────────────────────────────────────────────────────────────┘\u001b[39m\r\r\n\r\r\n"]
diff --git a/.vortex/docs/static/img/installer.svg b/.vortex/docs/static/img/installer.svg
index 365472179..767182f6e 100644
--- a/.vortex/docs/static/img/installer.svg
+++ b/.vortex/docs/static/img/installer.svg
@@ -1 +1 @@
-
\ No newline at end of file
+──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────██╗██╗██████╗██████╗████████╗███████╗██╗██╗██║██║██╔═══██╗██╔══██╗╚══██╔══╝██╔════╝╚██╗██╔╝██║██║██║██║██████╔╝██║█████╗╚███╔╝╚██╗██╔╝██║██║██╔══██╗██║██╔══╝██╔██╗╚████╔╝╚██████╔╝██║██║██║███████╗██╔╝██╗╚═══╝╚═════╝╚═╝╚═╝╚═╝╚══════╝╚═╝╚═╝DrupalprojecttemplatebyDrevOpsInstallerversion:development┌──────────────────────────────────────────────────────────────────────────────────────┐│WelcometotheVortexinteractiveinstaller││───────────────────────────────────────────││││ThistoolwillguideyouthroughinstallingthelateststableversionofVortexinto││yourproject.││Youwillbeaskedafewquestionstotailortheconfigurationtoyoursite.││Nochangeswillbemadeuntilyouconfirmeverythingattheend.││PressCtrl+Catanytimetoexittheinstaller.││PressCtrl+Uatanytimetogobacktothepreviousstep.│└────────────└──────────────────────────────────────────────────────────────────────────────────────┘Pressanykeytocontinue... General information ┌Sitename(1/29)────────────────────────────────────────────┐└──────────────────────────────────────────────────────────────┘Wewillusethisnameintheprojectanddocumentation.│E.g.MySite││St ┌Sitename(1/29)────────────────────────────────────────────┐│StarWars│┌Sitemachinename(2/29)────────────────────────────────────┐│star_wars│┌Organizationname(3/29)────────────────────────────────────┐│E.g.MyOrg│└────────────────────────────────────────────│Re │└─────────┌Organizationname(3/29)────────────────────────────────────┐│Rebellion│┌Organizationmachinename(4/29)────────────────────────────┐│rebellion │Wewillusethisnameinthecode.┌Organizationmachinename(4/29)────────────────────────────┐│rebellion│┌Publicdomain(5/29)────────────────────────────────────────┐│star-wars.com │┌Publicdomain(5/29)────────────────────────────────────────┐│star-wars.com│ Drupal ┌Howwouldyoulikeyoursitetobecreatedonthefirstrun?(6/29)┐│││Choosehowyoursitewillbecreatedthefirsttimeafterthis││installerfinishes:│││○Drupal,installedfromprofile││Createsanewsitebypopulatingafreshdatabase││fromoneofthestandardDrupalinstallationprofiles.││○DrupalCMS,installedfromprofile││fromtheDrupalCMSrecipe.││○Drupal,loadedfromthedemodatabase││Createsasitebyloadinganexistingdemodatabase││providedwiththeinstaller.│├─────────────────────────────────────────────────────────────────────┤│○Drupal,installedfromprofile││○DrupalCMS,installedfromprofile││›●Drupal,loadedfromthedemodatabase│└─────────────────────────────────────────────────────────────────────┘┌Howwouldyoulikeyoursitetobecreatedonthefirstrun?(6/29)┐│Drupal,loadedfromthedemodatabase│┌Profile(7/29)──────────────────────────────────────────────┐│Standard│┌Modules(8/29)──────────────────────────────────────────────┐│›◼Admintoolbar┃││◼Coffee│││◼Configsplit│││◼Configupdate│││◼Environmentindicator│││◼Pathauto│││◼Redirect│││◼Robots.txt│││◼Seckit│││◼Shield││┌Modules(8/29)──────────────────────────────────────────────┐│Admintoolbar││Coffee││Configsplit││Configupdate││Environmentindicator││Pathauto││Redirect││Robots.txt││Seckit││Shield││Stagefileproxy│┌Custommodulesprefix(9/29)────────────────────────────────┐│sw│┌Theme(10/29)───────────────────────────────────────────────┐│Custom(nextprompt)│┌Customthememachinename(11/29)───────────────────────────┐ Code repository ┌Repositoryprovider(12/29)─────────────────────────────────┐│││VortexoffersfullautomationwithGitHub,whilesupportfor││otherprovidersislimited.│├──────────────────────────────────────────────────────────────┤│›●GitHub││○Other│┌Repositoryprovider(12/29)─────────────────────────────────┐│GitHub│┌Releaseversioningscheme(13/29)───────────────────────────┐│Chooseyourversioningscheme:││○CalendarVersioning(CalVer)││year.month.patch(E.g.,24.1.0)││https://calver.org││○SemanticVersioning(SemVer)││major.minor.patch(E.g.,1.0.0)│major.minor.patch(E.g.,1.0.0)││https://semver.org││○Other││Customversioningschemeofyourchoice.││›●CalendarVersioning(CalVer)││○SemanticVersioning(SemVer)│┌Releaseversioningscheme(13/29)───────────────────────────┐│CalendarVersioning(CalVer)│ Environment ┌Timezone(14/29)────────────────────────────────────────────┐│UTC│┌Services(15/29)────────────────────────────────────────────┐│ClamAV││Solr││Redis│┌Developmenttools(16/29)───────────────────────────────────┐│›◼PHPCodeSniffer││◼PHPStan││◼Rector││◼PHPMessDetector││◼PHPUnit││◼Behat│┌Developmenttools(16/29)───────────────────────────────────┐│PHPCodeSniffer││PHPStan││Rector││PHPMessDetector││PHPUnit││Behat│ Hosting ┌Hostingprovider(17/29)────────────────────────────────────┐│None│┌Customwebrootdirectory(18/29)───────────────────────────┐│web │┌Customwebrootdirectory(18/29)───────────────────────────┐│web│ Deployment ┌Deploymenttypes(19/29)────────────────────────────────────┐│›◻Codeartifact││◻Lagoonwebhook││◻Containerimage┌Deploymenttypes(19/29)────────────────────────────────────┐│Customwebhook│ Workflow ┌Provisiontype(20/29)──────────────────────────────────────┐│Provisioningsetsupthesiteinanenvironmentusingan││alreadyassembledcodebase.││○Importfromdatabasedump││Provisionsthesitebyimportingadatabasedump││typicallycopiedfromproductionintolower││environments.││○Installfromprofile││ProvisionsthesitebyinstallingafreshDrupal││sitefromaprofileeverytimeanenvironmentis││created.│┌Provisiontype(20/29)──────────────────────────────────────┐│created.│Importfromdatabasedump│┌Databasesource(21/29)─────────────────────────────────────┐│›●URLdownload││○FTPdownload│○None│┌Databasesource(21/29)─────────────────────────────────────┐│URLdownload│ Notifications ┌Notificationchannels(22/29)───────────────────────────────┐│›◼Email││◻GitHub││◻JIRA││◻NewRelic││◻Slack││◻Webhook│┌Notificationchannels(22/29)───────────────────────────────┐│Email│ Continuous Integration ┌ContinuousIntegrationprovider(23/29)─────────────────────┐│›●GitHubActions││○CircleCI││○None┌ContinuousIntegrationprovider(23/29)─────────────────────┐│GitHubActions│ Automations ┌Dependencyupdatesprovider(24/29)─────────────────────────┐│›●RenovateGitHubapp││○Renovateself-hostedinCI│┌Dependencyupdatesprovider(24/29)─────────────────────────┐│RenovateGitHubapp││●Yes/○No│┌Auto-assigntheauthortotheirPR?(25/29)─────────────────┐│Yes│└─────────────────────────────────────────────────────────────────┘┌Auto-addaCONFLICTlabeltoaPRwhenconflictsoccur?(26/29)┐│Yes│ Documentation ┌Preserveprojectdocumentation?(27/29)─────────────────────┐┌Preserveprojectdocumentation?(27/29)─────────────────────┐ AI ┌AIcodeassistantinstructions(28/29)──────────────────────┐│›●AnthropicClaude│┌AIcodeassistantinstructions(28/29)──────────────────────┐│AnthropicClaude│ Installation summary ┌────────────────────────────────────┬─────────────────────────────────────────┐│Generalinformation│││Sitename│StarWars││Sitemachinename│star_wars││Organizationname│Rebellion││Organizationmachinename│rebellion││Publicdomain│star-wars.com││Drupal│││Starter│load_demodb││Modules│admin_toolbar,coffee,config_split,│││config_update,environment_indicator,│││pathauto,redirect,robotstxt,seckit,│││shield,stage_file_proxy││Webroot│web││Profile│standard││Moduleprefix│sw││Thememachinename│Thememachinename│star_wars││Coderepository│││Codeprovider│github││Versionscheme│calver││Environment│││Timezone│UTC││Services│clamav,redis,solr││Tools│phpcs,phpmd,phpstan,rector,phpunit,│││behat││Hosting│││Hostingprovider│none││Deployment│││Deploymenttypes│webhook││Workflow│││Provisiontype│database││Databasesource│url││Notifications│││Channels│email││ContinuousIntegration│││CIprovider│gha││Automations│││Dependencyupdatesprovider│renovatebot_app││Auto-assignPRauthor│Yes││Auto-addaCONFLICTlabeltoPRs│Yes││Documentation│││Preserveprojectdocumentation│Yes││AI│││AIcodeassistantinstructions│claude││Locations│Locations│││Currentdirectory│/home/user/www/demo││Destinationdirectory│/home/user/www/demo/star_wars││Vortexrepository│https://github.com/drevops/vortex.git││Vortexreference│stable│└────────────────────────────────────┴─────────────────────────────────────────┘Vortexwillbeinstalledintoyourproject'sdirectory"/home/user/www/demo/star_wars"┌ProceedwithinstallingVortex?─────────────────────────────┐ Starting project installation ✦DownloadingVortexDownloadingfrom"https://github.com/drevops/vortex.git"repositoryatcommit"HEAD"✓Vortexdownloaded(25.10.0)⠤CustomizingVortexforyourproject⠄CustomizingVortexforyourproject⠆CustomizingVortexforyourproject✦CustomizingVortexforyourproject✓Vortexwascustomizedforyourproject✦PreparingdestinationdirectoryCreateddirectory"/home/user/www/demo/star_wars".InitialisinganewGitrepositoryindirectory"/home/user/www/demo/star_wars".✓Destinationdirectoryisready✦Copyingfilestothedestinationdirectory✓Filescopiedtodestinationdirectory✦PreparingdemocontentCreateddatadirectory"/home/user/www/demo/star_wars/.data".Nodatabasedumpfilewasfoundin"/home/user/www/demo/star_wars/.data"directory.Downloadeddemodatabasefromhttps://github.com/drevops/vortex/releases/download/25.4.0/db_d11.demo.sql.✓Democontentprepared┌───────────────────────────────────┐│FinishedinstallingVortex││──────────────────────────││││Addandcommitallfiles:││gitadd-A││gitcommit-m"Initialcommit."│└───────────────────────────────────┘┌Runthesitebuildnow?─────────────────────────────────────┐│No│┌────────────────────────────────────────────────────────────────────────────────────────┐│Readytobuild││──────────────││││Buildthesite:││ahoybuild│╚██╗██╔╝█╚██╗██╔╝██║────────────────────────────────────────────────────────────────────────────────────────────────────────────────│yourproject.└───────│starwars │└──────────────────────────────────└────────────────────────────────────│St ││StarWars │┌Sitemachinename(2/29)────────────────────────────────────┐│star_wars │Wewillusethisnamefortheprojectdirectoryandinthecode.│StarWarsOrg │└───────────────────────────────────────────────└─────│Rebellion │WewillusethisnameinthecoDomainnamewithoutproDomainnamewithoutprotocolandDomainnamewithoutprotocolandtrailingslash.│Createsasitebyloadin│Createsasitebyloadinganexist└────────────────────────────────────────────────────────────└──────────────────────────────────────────────────────────────Use⬆and⬇.Appliesonlyonthefirstrunoftheinstaller.│○DrupalCMS,installedfromprofile│Drupal,loadedfromthedemodatabase┌Profile(7/29)──────────────────────────────────────────────┐│›●Standard││○Minimal││○DemoUmami││○Custom(nextprompt)│Use⬆and⬇toselectwhichDrupalprofiletouse.│◼Configspli│◼Configsplit└───────────────────────└──────────────────────────┌Theme(10/29)──────┌Theme(10/29)─────────Use⬆and⬇toselectyourcoderepositoryprovider.└────────────────────────────────────────────────────└───────────────────────────────────────────────────────│○OtherUse⬆and⬇toselectyourversionscheme.┌Timezone(14/29)┌Timezone(14/29)────└───────────────────────────────────────────────────└──────────────────────────────────────────────────────Use⬆,⬇andSpacebartoselectoneormoretools.┌Hostingprovider(17/29)────────────────────────────────────┐│○AcquiaCloud││○Lagoon││›●None│Use⬆,⬇andSpacebartoselectyourhostingprovider.CustomdirectorywherethewebseCustomdirectorywherethewebserverservCustomdirectorywherethewebserverservesthesite.│◻Containerimage││◼Customwebhook│Use⬆,⬇andSpacebartoselectoneormoredeploymenttypes.├─├────│Provisioningsetsupthesiteinane│Provisioningsetsupthesiteinanenvironment│○FTPdownload││○Acquiabackup││○Lagoonenvironment││○Containerregistry│Use⬆and⬇toselectthedatabasedownloadsource.└────────────────────────────────────────Use⬆,⬇andSpacebartoselectoneormorenotificationchannels.Use⬆and⬇toselecttheCIprovider.└Use⬆and⬇toselectthedependencyupdatesprovider.┌Auto-assigntheauthortotheirPR?(25/29)─────────────────┐HelpstokeepthePRsorganized.┌Auto-addaCONFLICTlabeltoaPRwhenconflictsoccur?(26/29)┐│●Yes/○No│HelpstokeepquicklyidentifyPRsthatneedattention.HelpstomaintaintheprojectdocumentationHelpstomaintaintheprojectdocumentationwithintheHelpstomaintaintheprojectdocumentationwithintherepository.└──────────────────────────────────────└──────────────────────────────────────────ProvidesAIcodingassistantswithbettercontextabouttheproject.│Generalinformation││Modul│Modules│Tools│Provisiontype│Dep│Dependencyupdatesprovi┌ProceedwithinstallingVortex?─────────────────────────────┐⠆DownloadingVortex⠂⠤⠤CustomizingVortexforyourprojec⠂CustomizingVortexforyourproject⠒CustomizingVortexforyourproject⠐CustomizingVortexforyourproject⠰CustomizingVortexforyourproject⠠CustomizingVortexforyourproject⠂Preparingdestinationdirectory⠂Copyingfilestothedestinationdirectory⠂Preparingdemocontent⠒Preparingdemocontent⠐Preparingdemocontent⠰Preparingdemocontent⠠Preparingdemocontent┌Runthesitebuildnow?─────────────────────────────────────┐│○Yes/●No│Takes~5-10min;outputwillbestreamed.Youcanskipandrunlaterwith:ahoybuild│SetupGitHubActions:││https://www.vortextemplate.com/docs/continuous-integration/github-actions#onboarding│└────────────────────────────────────────────────────────────────────────────────────────┘
\ No newline at end of file
diff --git a/.vortex/installer/installer.php b/.vortex/installer/installer.php
index 4670743e6..0cb07a484 100755
--- a/.vortex/installer/installer.php
+++ b/.vortex/installer/installer.php
@@ -8,6 +8,8 @@
declare(strict_types=1);
+use DrevOps\VortexInstaller\Command\BuildCommand;
+use DrevOps\VortexInstaller\Command\CheckRequirementsCommand;
use DrevOps\VortexInstaller\Command\InstallCommand;
use Symfony\Component\Console\Application;
@@ -15,8 +17,10 @@
$application = new Application('Vortex Installer', '@vortex-installer-version@');
-$command = new InstallCommand();
-$application->add($command);
-$application->setDefaultCommand($command->getName(), TRUE);
+$application->add(new InstallCommand());
+$application->add(new CheckRequirementsCommand());
+$application->add(new BuildCommand());
+
+$application->setDefaultCommand('install');
$application->run();
diff --git a/.vortex/installer/phpstan.neon b/.vortex/installer/phpstan.neon
index 0e764f9b1..37d4fd00d 100644
--- a/.vortex/installer/phpstan.neon
+++ b/.vortex/installer/phpstan.neon
@@ -16,6 +16,8 @@ parameters:
excludePaths:
- vendor/*
+ treatPhpDocTypesAsCertain: false
+
ignoreErrors:
-
# Since tests and data providers do not have to have parameter docblocks,
diff --git a/.vortex/installer/phpunit.xml b/.vortex/installer/phpunit.xml
index b621a7294..0757f711e 100644
--- a/.vortex/installer/phpunit.xml
+++ b/.vortex/installer/phpunit.xml
@@ -1,5 +1,5 @@
-writeln("Line 1 via Tui::output()...");
+ usleep(500000);
+ Tui::output()->writeln("Line 2 via Tui::output()...");
+ usleep(500000);
+ Tui::output()->writeln("Line 3 via Tui::output()...");
+ usleep(500000);
+ return true;
+ },
+ success: 'Tui::output() streaming completed',
+ streaming: true,
+);
+echo PHP_EOL;
+
+// Streaming mode with mixed output.
+echo "--- Streaming mode: mixed echo and Tui::output() ---" . PHP_EOL;
+Task::action(
+ label: 'Streaming task with mixed output',
+ action: function () {
+ echo "Line 1 via echo...\n";
+ usleep(400000);
+ Tui::output()->writeln("Line 2 via Tui::output()...");
+ usleep(400000);
+ echo "Line 3 via echo...\n";
+ usleep(400000);
+ Tui::output()->writeln("Line 4 via Tui::output()...");
+ usleep(400000);
+ return true;
+ },
+ success: 'Mixed streaming completed',
+ streaming: true,
+);
+echo PHP_EOL;
+
+// Streaming mode with failure.
+echo "--- Streaming mode: failure case ---" . PHP_EOL;
+Task::action(
+ label: 'Streaming task that fails',
+ action: function () {
+ echo "Starting process...\n";
+ usleep(500000);
+ echo "Error encountered!\n";
+ usleep(500000);
+ return false;
+ },
+ failure: 'Streaming task failed',
+ streaming: true,
+);
+echo PHP_EOL;
+
+// Task after failure - verify output is restored.
+echo "--- Task after failure: verify output restoration ---" . PHP_EOL;
+Task::action(
+ label: 'Task after failed streaming',
+ action: function () {
+ echo "This echo should be dimmed\n";
+ usleep(500000);
+ Tui::output()->writeln("This Tui::output() should also be dimmed");
+ usleep(500000);
+ return true;
+ },
+ success: 'Output restoration verified',
+ streaming: true,
+);
+echo PHP_EOL;
+
+// Streaming mode without success message (default "OK").
+echo "--- Streaming mode: no success message (default OK) ---" . PHP_EOL;
+Task::action(
+ label: 'Streaming task without success message',
+ action: function () {
+ echo "Some output...\n";
+ usleep(500000);
+ return true;
+ },
+ streaming: true,
+);
+echo PHP_EOL;
+
+// Streaming mode with nested spinner (simulates build command with requirements check).
+echo "--- Streaming mode: nested spinner (cursor control) ---" . PHP_EOL;
+Task::action(
+ label: 'Streaming task with nested spinner',
+ action: function () {
+ // The nested command uses spin() which outputs cursor control sequences.
+ \Laravel\Prompts\spin(
+ function () {
+ usleep(300000);
+ usleep(300000);
+ usleep(300000);
+ },
+ 'Nested spinner task...'
+ );
+
+ echo "AFTER SPINNER 1\n";
+ \Laravel\Prompts\spin(
+ function () {
+ usleep(1000000);
+ usleep(1000000);
+ },
+ 'Another nested spinner task...'
+ );
+
+ echo "AFTER SPINNER 2\n";
+
+ return true;
+ },
+ streaming: true,
+);
+echo PHP_EOL;
+
+// Streaming mode with colors and styles.
+echo "--- Streaming mode: colors and styles ---" . PHP_EOL;
+Task::action(
+ label: 'Streaming task with styled output',
+ action: function () {
+ Tui::output()->writeln(Tui::green("Green text"));
+ usleep(300000);
+ Tui::output()->writeln(Tui::blue("Blue text"));
+ usleep(300000);
+ Tui::output()->writeln(Tui::yellow("Yellow text"));
+ usleep(300000);
+ Tui::output()->writeln(Tui::underscore("Underscored text"));
+ usleep(300000);
+ Tui::output()->writeln(Tui::bold("Bold text"));
+ usleep(300000);
+ Tui::output()->writeln("Mixed: " . Tui::green("green") . " and " . Tui::blue("blue") . " and " . Tui::underscore("underscored"));
+ usleep(300000);
+ return true;
+ },
+ success: 'Styled streaming completed',
+ streaming: true,
+);
+echo PHP_EOL;
+
+// Non-streaming task after streaming tasks.
+echo "--- Non-streaming task (spinner) after streaming ---" . PHP_EOL;
+Task::action(
+ label: 'Spinner task after streaming',
+ action: function () {
+ usleep(1000000);
+ return true;
+ },
+ success: 'Spinner works after streaming',
+);
+echo PHP_EOL;
+
+echo "=== Demo Complete ===" . PHP_EOL;
+echo PHP_EOL;
diff --git a/.vortex/installer/playground/task.php b/.vortex/installer/playground/task.php
new file mode 100755
index 000000000..8d2ef646e
--- /dev/null
+++ b/.vortex/installer/playground/task.php
@@ -0,0 +1,268 @@
+#!/usr/bin/env php
+ "Success received: $result",
+);
+echo PHP_EOL;
+
+// Action returns integer, success callback uses it.
+echo "--- Action returns integer ---" . PHP_EOL;
+Task::action(
+ label: 'Action returns integer 42',
+ action: function () {
+ sleep(1);
+ return 42;
+ },
+ success: fn($count) => "Success received integer: $count",
+);
+echo PHP_EOL;
+
+// Static success message (not a callback).
+echo "--- Static success message (string, not callback) ---" . PHP_EOL;
+Task::action(
+ label: 'Action with static success string',
+ action: function () {
+ sleep(1);
+ return true;
+ },
+ success: 'Static success message',
+);
+echo PHP_EOL;
+
+// Task with hint parameter.
+echo "--- Hint parameter shown below label ---" . PHP_EOL;
+Task::action(
+ label: 'Label with hint parameter',
+ action: function () {
+ sleep(2);
+ return true;
+ },
+ hint: 'This hint appears dimmed below the label',
+ success: 'Completed with hint',
+);
+echo PHP_EOL;
+
+// Action returns false - triggers failure path.
+echo "--- Action returns false, triggers failure ---" . PHP_EOL;
+Task::action(
+ label: 'Action returns false',
+ action: function () {
+ sleep(1);
+ return false;
+ },
+ failure: 'Custom failure message',
+);
+echo PHP_EOL;
+
+// Action returns false with default failure message.
+echo "--- Action returns false, default failure message ---" . PHP_EOL;
+Task::action(
+ label: 'Action returns false, no failure param',
+ action: function () {
+ usleep(500000);
+ return false;
+ },
+);
+echo PHP_EOL;
+
+// Action returns array - displayed as sublist.
+echo "--- Action returns array, displayed as sublist ---" . PHP_EOL;
+Task::action(
+ label: 'Action returns array of strings',
+ action: function () {
+ sleep(1);
+ return [
+ 'Array item 1',
+ 'Array item 2',
+ 'Array item 3',
+ ];
+ },
+ success: 'Array items shown above',
+);
+echo PHP_EOL;
+
+// Dynamic label from closure.
+echo "--- Label as closure (evaluated at runtime) ---" . PHP_EOL;
+$dynamic_value = 'dynamic_' . rand(100, 999);
+Task::action(
+ label: fn() => "Label from closure: $dynamic_value",
+ action: function () {
+ sleep(1);
+ return true;
+ },
+);
+echo PHP_EOL;
+
+// Longer spinner duration.
+echo "--- Longer duration (3s) to see spinner animation ---" . PHP_EOL;
+Task::action(
+ label: 'Spinner runs for 3 seconds',
+ action: function () {
+ sleep(3);
+ return true;
+ },
+ success: 'Spinner completed',
+);
+echo PHP_EOL;
+
+// Very short duration.
+echo "--- Very short duration (100ms) ---" . PHP_EOL;
+Task::action(
+ label: 'Spinner for 100ms only',
+ action: function () {
+ usleep(100000);
+ return true;
+ },
+);
+echo PHP_EOL;
+
+// Multiple tasks in sequence.
+echo "--- Multiple sequential tasks ---" . PHP_EOL;
+for ($i = 1; $i <= 3; $i++) {
+ Task::action(
+ label: "Sequential task $i of 3",
+ action: function () use ($i) {
+ usleep($i * 300000);
+ return true;
+ },
+ success: "Task $i done",
+ );
+}
+echo PHP_EOL;
+
+// Hint as closure.
+echo "--- Hint as closure (evaluated at runtime) ---" . PHP_EOL;
+Task::action(
+ label: 'Task with dynamic hint',
+ action: function () {
+ sleep(1);
+ return true;
+ },
+ hint: fn() => 'Hint from closure: ' . date('H:i:s'),
+);
+echo PHP_EOL;
+
+// Success as closure receiving null (when action returns true).
+echo "--- Success callback receives true (not useful) ---" . PHP_EOL;
+Task::action(
+ label: 'Action returns true, success gets true',
+ action: function () {
+ sleep(1);
+ return true;
+ },
+ success: fn($result) => "Success callback got: " . var_export($result, true),
+);
+echo PHP_EOL;
+
+// Failure as closure.
+echo "--- Failure as closure ---" . PHP_EOL;
+Task::action(
+ label: 'Action returns false, failure is closure',
+ action: function () {
+ usleep(500000);
+ return false;
+ },
+ failure: fn() => 'Failure from closure: ' . date('H:i:s'),
+);
+echo PHP_EOL;
+
+// Streaming mode - no spinner, output streams during action.
+echo "--- Streaming mode: output streams during action ---" . PHP_EOL;
+Task::action(
+ label: 'Streaming task with output',
+ action: function () {
+ echo "Line 1 of output...\n";
+ usleep(500000);
+ echo "Line 2 of output...\n";
+ usleep(500000);
+ echo "Line 3 of output...\n";
+ usleep(500000);
+ return true;
+ },
+ success: 'Streaming completed',
+ streaming: true,
+);
+echo PHP_EOL;
+
+// Streaming mode with failure.
+echo "--- Streaming mode: action returns false ---" . PHP_EOL;
+Task::action(
+ label: 'Streaming task that fails',
+ action: function () {
+ echo "Starting process...\n";
+ usleep(500000);
+ echo "Error encountered!\n";
+ usleep(500000);
+ return false;
+ },
+ failure: 'Streaming task failed',
+ streaming: true,
+);
+echo PHP_EOL;
+
+// Streaming mode with longer output.
+echo "--- Streaming mode: simulated build output ---" . PHP_EOL;
+Task::action(
+ label: 'Building project',
+ action: function () {
+ $steps = [
+ 'Installing dependencies...',
+ 'Compiling assets...',
+ 'Running migrations...',
+ 'Clearing caches...',
+ 'Build complete.',
+ ];
+ foreach ($steps as $step) {
+ echo "$step\n";
+ usleep(400000);
+ }
+ return true;
+ },
+ success: 'Project built successfully',
+ streaming: true,
+);
+echo PHP_EOL;
+
+echo "=== Demo Complete ===" . PHP_EOL;
+echo PHP_EOL;
diff --git a/.vortex/installer/playground/tui.php b/.vortex/installer/playground/tui.php
new file mode 100755
index 000000000..54bca3b7e
--- /dev/null
+++ b/.vortex/installer/playground/tui.php
@@ -0,0 +1,187 @@
+#!/usr/bin/env php
+ 'Black',
+ 31 => 'Red',
+ 32 => 'Green',
+ 33 => 'Yellow',
+ 34 => 'Blue',
+ 35 => 'Magenta',
+ 36 => 'Cyan',
+ 37 => 'White',
+ 90 => 'Bright Black',
+ 91 => 'Bright Red',
+ 92 => 'Bright Green',
+ 93 => 'Bright Yellow',
+ 94 => 'Bright Blue',
+ 95 => 'Bright Magenta',
+ 96 => 'Bright Cyan',
+ 97 => 'Bright White',
+];
+
+foreach ($colors as $code => $name) {
+ $w = 15;
+ $pad = str_repeat(' ', max(0, $w - strlen($name)));
+
+ // Style codes: 1=bold, 2=dim, 4=underscore.
+ $normal = sprintf("\033[%sm%s\033[0m%s", $code, $name, $pad);
+ $dim = sprintf("\033[2;%sm%s\033[0m%s", $code, $name, $pad);
+ $under = sprintf("\033[4;%sm%s\033[0m%s", $code, $name, $pad);
+ $under_dim = sprintf("\033[2;4;%sm%s\033[0m%s", $code, $name, $pad);
+ $bold = sprintf("\033[1;%sm%s\033[0m%s", $code, $name, $pad);
+ $bold_dim = sprintf("\033[1;2;%sm%s\033[0m%s", $code, $name, $pad);
+ $bold_under = sprintf("\033[1;4;%sm%s\033[0m%s", $code, $name, $pad);
+ $bold_under_dim = sprintf("\033[1;2;4;%sm%s\033[0m%s", $code, $name, $pad);
+
+ echo sprintf("%3d: %s %s %s %s %s %s %s %s", $code, $normal, $dim, $under, $under_dim, $bold, $bold_dim, $bold_under, $bold_under_dim) . PHP_EOL;
+}
+echo PHP_EOL;
+
+// Tui helper colors.
+echo "--- Tui Helper Colors ---" . PHP_EOL;
+echo Tui::green("This is green text") . PHP_EOL;
+echo Tui::blue("This is blue text") . PHP_EOL;
+echo Tui::purple("This is purple text") . PHP_EOL;
+echo Tui::yellow("This is yellow text") . PHP_EOL;
+echo Tui::cyan("This is cyan text") . PHP_EOL;
+echo PHP_EOL;
+
+// Text styles.
+echo "--- Text Styles ---" . PHP_EOL;
+echo Tui::bold("This is bold text") . PHP_EOL;
+echo Tui::underscore("This is underscored text") . PHP_EOL;
+echo Tui::dim("This is dimmed text") . PHP_EOL;
+echo "This is normal text for comparison" . PHP_EOL;
+echo PHP_EOL;
+
+// Combinations.
+echo "--- Combinations ---" . PHP_EOL;
+echo Tui::bold(Tui::green("Bold green text")) . PHP_EOL;
+echo Tui::dim(Tui::cyan("Dimmed cyan text")) . PHP_EOL;
+echo Tui::bold(Tui::yellow("Bold yellow text")) . PHP_EOL;
+echo PHP_EOL;
+
+// Dim with reset codes (simulating external command output).
+echo "--- Dim with embedded resets ---" . PHP_EOL;
+$simulated_output = "\033[32mGreen text\033[0m then normal \033[34mblue text\033[0m end";
+echo "Original: " . $simulated_output . PHP_EOL;
+echo "Dimmed: " . Tui::dim($simulated_output) . PHP_EOL;
+echo PHP_EOL;
+
+// Multiline.
+echo "--- Multiline ---" . PHP_EOL;
+$multiline = "Line one\nLine two\nLine three";
+echo Tui::green($multiline) . PHP_EOL;
+echo PHP_EOL;
+echo Tui::dim($multiline) . PHP_EOL;
+echo PHP_EOL;
+
+// Box.
+echo "--- Box ---" . PHP_EOL;
+Tui::box("This is content inside a box.\nIt can have multiple lines.", "Box Title");
+echo PHP_EOL;
+
+// Info/Note/Error (Laravel Prompts styles).
+echo "--- Messages ---" . PHP_EOL;
+Tui::info("This is an info message");
+Tui::note("This is a note message");
+Tui::success("This is a success message");
+Tui::error("This is an error message");
+echo PHP_EOL;
+
+// List.
+echo "--- List ---" . PHP_EOL;
+Tui::list([
+ 'Project name' => 'my_project',
+ 'Machine name' => 'my_project',
+ 'Organization' => 'My Organization',
+ 'Services' => Tui::LIST_SECTION_TITLE,
+ 'Database' => 'MySQL 8.0',
+ 'Cache' => 'Redis',
+ 'Search' => 'Solr',
+], 'Configuration Summary');
+echo PHP_EOL;
+
+// Terminal width.
+$term_width = Tui::terminalWidth();
+echo "--- Terminal Info ---" . PHP_EOL;
+echo "Terminal width: " . $term_width . " columns" . PHP_EOL;
+echo PHP_EOL;
+
+// Ruler function.
+$make_ruler = function(int $width): string {
+ $ruler_top = '';
+ $ruler_num = '';
+ $ruler_bot = '';
+ for ($i = 1; $i <= $width; $i++) {
+ if ($i % 10 === 0) {
+ $ruler_top .= '|';
+ $num = (string)$i;
+ $ruler_num .= $num[strlen($num) - 2] ?? ' ';
+ $ruler_bot .= $num[strlen($num) - 1];
+ } elseif ($i % 5 === 0) {
+ $ruler_top .= '+';
+ $ruler_num .= ' ';
+ $ruler_bot .= '5';
+ } else {
+ $ruler_top .= '-';
+ $ruler_num .= ' ';
+ $ruler_bot .= ' ';
+ }
+ }
+ return $ruler_top . PHP_EOL . $ruler_num . PHP_EOL . $ruler_bot;
+};
+
+// Visual terminal boundaries with ruler.
+echo "--- Terminal Boundaries with Ruler ---" . PHP_EOL;
+echo $make_ruler($term_width) . PHP_EOL;
+echo "|" . str_repeat(" ", $term_width - 2) . "|" . PHP_EOL;
+echo str_repeat("=", $term_width) . PHP_EOL;
+echo PHP_EOL;
+
+// Center.
+echo "--- Centered Text (within terminal width: $term_width) ---" . PHP_EOL;
+echo "|" . str_repeat("-", $term_width - 2) . "|" . PHP_EOL;
+echo Tui::center("Centered Title", $term_width) . PHP_EOL;
+echo Tui::center("Line 1\nLine 2\nLonger Line 3", $term_width) . PHP_EOL;
+echo "|" . str_repeat("-", $term_width - 2) . "|" . PHP_EOL;
+echo PHP_EOL;
+
+// Center with fixed width.
+$fixed_width = 60;
+echo "--- Centered Text (fixed width: $fixed_width) ---" . PHP_EOL;
+echo "|" . str_repeat("-", $fixed_width - 2) . "|" . PHP_EOL;
+echo Tui::center("Centered Title", $fixed_width) . PHP_EOL;
+echo Tui::center("Short\nMedium text\nThis is a longer line", $fixed_width) . PHP_EOL;
+echo "|" . str_repeat("-", $fixed_width - 2) . "|" . PHP_EOL;
+echo PHP_EOL;
+
+echo "=== Demo Complete ===" . PHP_EOL;
+echo PHP_EOL;
diff --git a/.vortex/installer/src/Command/BuildCommand.php b/.vortex/installer/src/Command/BuildCommand.php
new file mode 100644
index 000000000..9a31e5db8
--- /dev/null
+++ b/.vortex/installer/src/Command/BuildCommand.php
@@ -0,0 +1,194 @@
+setName('build');
+ $this->setDescription('Build the site using ahoy build.');
+ $this->setHelp('Checks requirements and runs ahoy build to set up the local site.');
+ $this->addOption(static::OPTION_PROFILE, 'p', InputOption::VALUE_NONE, 'Build from install profile instead of loading database.');
+ $this->addOption(static::OPTION_SKIP_REQUIREMENTS_CHECK, NULL, InputOption::VALUE_NONE, 'Skip checking for required tools.');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ Tui::init($output);
+
+ $this->isProfile = (bool) $input->getOption(static::OPTION_PROFILE);
+ $cwd = getcwd() ?: '.';
+
+ if (!$input->getOption(static::OPTION_SKIP_REQUIREMENTS_CHECK)) {
+ $requirements_ok = Task::action(
+ label: 'Checking requirements',
+ action: function (): bool {
+ $command_runner = $this->getCommandRunner()->disableLog();
+ $command_runner->run('check-requirements', [], ['--no-summary' => '1']);
+
+ return $command_runner->getExitCode() === RunnerInterface::EXIT_SUCCESS;
+ },
+ failure: 'Missing requirements. Run: ./installer.php check-requirements',
+ streaming: TRUE,
+ );
+
+ if (!$requirements_ok) {
+ return Command::FAILURE;
+ }
+ }
+
+ $build_ok = Task::action(
+ label: 'Building site',
+ action: function () use ($cwd): bool {
+ $env = [
+ 'AHOY_CONFIRM_RESPONSE' => 'y',
+ 'AHOY_CONFIRM_WAIT_SKIP' => '1',
+ ];
+
+ if ($this->isProfile) {
+ $env['VORTEX_PROVISION_TYPE'] = 'profile';
+ }
+
+ $this->processRunner = $this->getProcessRunner()->setCwd($cwd);
+ $this->processRunner->run('ahoy build', env: $env);
+
+ return $this->processRunner->getExitCode() === RunnerInterface::EXIT_SUCCESS;
+ },
+ success: fn(bool $result): string => $result ? 'Build completed' : 'Build failed',
+ failure: 'Build failed',
+ streaming: TRUE,
+ );
+
+ if ($build_ok) {
+ $this->showSuccessSummary();
+ return Command::SUCCESS;
+ }
+
+ $this->showFailureSummary();
+ return Command::FAILURE;
+ }
+
+ /**
+ * Get the project machine name from .env.
+ */
+ protected function getProjectMachineName(): string {
+ $cwd = getcwd() ?: '.';
+ $env_file = $cwd . '/.env';
+
+ if (file_exists($env_file)) {
+ $content = file_get_contents($env_file);
+ if ($content !== FALSE && preg_match('/^VORTEX_PROJECT=(.+)$/m', $content, $matches)) {
+ return trim($matches[1]);
+ }
+ }
+
+ return basename($cwd);
+ }
+
+ /**
+ * Display success summary.
+ */
+ protected function showSuccessSummary(): void {
+ $output = '';
+ $title = 'Build completed successfully!';
+
+ $output .= 'Site URL: http://' . $this->getProjectMachineName() . '.docker.amazee.io' . PHP_EOL;
+ $output .= 'Login: ahoy login' . PHP_EOL;
+ $output .= PHP_EOL;
+
+ $log_path = $this->processRunner->getLogger()->getPath();
+ if ($log_path) {
+ $output .= 'Log file: ' . $log_path . PHP_EOL;
+ $output .= PHP_EOL;
+ }
+
+ $output .= 'Next steps:' . PHP_EOL;
+ if ($this->isProfile) {
+ $output .= ' - Export database: ahoy export-db' . PHP_EOL;
+ }
+ $output .= ' - Review hosting/provisioning docs' . PHP_EOL;
+
+ Tui::box($output, $title);
+ }
+
+ /**
+ * Display failure summary.
+ */
+ protected function showFailureSummary(): void {
+ Tui::line('');
+
+ $command = $this->processRunner->getCommand();
+ if ($command) {
+ Tui::line('Failed at: ' . $command);
+ }
+
+ $exit_code = $this->processRunner->getExitCode();
+ Tui::line('Exit code: ' . $exit_code);
+
+ $log_path = $this->processRunner->getLogger()->getPath();
+ if ($log_path) {
+ Tui::line('Log file: ' . $log_path);
+ }
+
+ Tui::line('');
+
+ // Show last 10 lines of output for context.
+ $runner_output = $this->processRunner->getOutput(as_array: TRUE);
+
+ if (!is_array($runner_output)) {
+ throw new \RuntimeException('Runner output is not an array.');
+ }
+
+ $last_lines = array_slice($runner_output, -10);
+ if (!empty($last_lines)) {
+ Tui::line('Last output:');
+ foreach ($last_lines as $last_line) {
+ Tui::line(' ' . $last_line);
+ }
+ }
+ }
+
+}
diff --git a/.vortex/installer/src/Command/CheckRequirementsCommand.php b/.vortex/installer/src/Command/CheckRequirementsCommand.php
new file mode 100644
index 000000000..c9a0ca3c0
--- /dev/null
+++ b/.vortex/installer/src/Command/CheckRequirementsCommand.php
@@ -0,0 +1,338 @@
+
+ */
+ protected array $present = [];
+
+ /**
+ * Missing tools with installation instructions.
+ *
+ * @var array
+ */
+ protected array $missing = [];
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function configure(): void {
+ $this->setName('check-requirements');
+ $this->setDescription('Check if required tools are installed and running.');
+ $this->setHelp('Checks for Docker, Docker Compose, Ahoy, and Pygmy.');
+ $this->addOption(static::OPTION_ONLY, 'o', InputOption::VALUE_REQUIRED, sprintf('Comma-separated list of requirements to check. Available: %s.', implode(', ', static::REQUIREMENTS)));
+ $this->addOption(static::OPTION_NO_SUMMARY, NULL, InputOption::VALUE_NONE, 'Hide summary with tool versions.');
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ Tui::init($output);
+
+ $only = $input->getOption(static::OPTION_ONLY);
+ $requirements = $this->validateRequirements($only ? array_map(trim(...), explode(',', (string) $only)) : NULL);
+
+ $this->processRunner ??= $this->getProcessRunner();
+ $this->present = [];
+ $this->missing = [];
+
+ if (in_array(static::REQ_DOCKER, $requirements, TRUE)) {
+ Task::action(
+ label: 'Checking Docker',
+ action: fn(): bool => $this->checkDocker(),
+ success: fn(bool $result): string => $result ? 'Docker is available' : 'Docker is missing',
+ );
+ }
+
+ if (in_array(static::REQ_DOCKER_COMPOSE, $requirements, TRUE)) {
+ Task::action(
+ label: 'Checking Docker Compose',
+ action: fn(): bool => $this->checkDockerCompose(),
+ success: fn(bool $result): string => $result ? 'Docker Compose is available' : 'Docker Compose is missing',
+ );
+ }
+
+ if (in_array(static::REQ_AHOY, $requirements, TRUE)) {
+ Task::action(
+ label: 'Checking Ahoy',
+ action: fn(): bool => $this->checkAhoy(),
+ success: fn(bool $result): string => $result ? 'Ahoy is available' : 'Ahoy is missing',
+ );
+ }
+
+ if (in_array(static::REQ_PYGMY, $requirements, TRUE)) {
+ Task::action(
+ label: 'Checking Pygmy',
+ action: fn(): bool => $this->checkPygmy(),
+ success: fn(bool $result): string => $result ? 'Pygmy is running' : 'Pygmy is not running',
+ );
+ }
+
+ if (!$input->getOption(static::OPTION_NO_SUMMARY)) {
+ $summary = $this->getResultsSummary();
+ Tui::box($summary['content'], $summary['title']);
+ }
+ elseif (empty($this->missing)) {
+ Tui::success('All requirements met.');
+ }
+
+ return empty($this->missing) ? Command::SUCCESS : Command::FAILURE;
+ }
+
+ /**
+ * Validate and return requirements to check.
+ *
+ * @param array|null $only
+ * Array of requirement names to check. NULL to check all.
+ *
+ * @return array
+ * Array of validated requirement names.
+ *
+ * @throws \InvalidArgumentException
+ * If an unknown requirement is specified.
+ */
+ protected function validateRequirements(?array $only): array {
+ if ($only !== NULL) {
+ $unknown = array_diff($only, static::REQUIREMENTS);
+ if (!empty($unknown)) {
+ throw new \InvalidArgumentException(sprintf("Unknown requirements: %s.\nAvailable: %s.", implode(', ', $unknown), implode(', ', static::REQUIREMENTS)));
+ }
+ }
+
+ return $only ?? static::REQUIREMENTS;
+ }
+
+ /**
+ * Get present tools.
+ *
+ * @return array
+ * An array of present tools with tool name as key and path as value.
+ */
+ public function getPresent(): array {
+ return $this->present;
+ }
+
+ /**
+ * Get missing tools.
+ *
+ * @return array
+ * An array of missing tools with tool name as key and message as value.
+ */
+ public function getMissing(): array {
+ return $this->missing;
+ }
+
+ /**
+ * Get all check results merged.
+ *
+ * @return array
+ * Combined array of present and missing tools.
+ */
+ public function getResults(): array {
+ return array_merge($this->present, $this->missing);
+ }
+
+ /**
+ * Get a formatted summary of check results.
+ *
+ * @return array{title: string, content: string}
+ * Array with 'title' and 'content' keys for the summary.
+ */
+ public function getResultsSummary(): array {
+ $content = '';
+
+ if (!empty($this->present)) {
+ $content .= 'Present:' . PHP_EOL;
+ foreach ($this->present as $tool => $status) {
+ $content .= ' - ' . $tool . ': ' . $status . PHP_EOL;
+ }
+ }
+
+ if (!empty($this->missing)) {
+ if (!empty($content)) {
+ $content .= PHP_EOL;
+ }
+ $content .= 'Missing:' . PHP_EOL;
+ foreach ($this->missing as $tool => $instruction) {
+ $content .= ' - ' . $tool . ': ' . $instruction . PHP_EOL;
+ }
+ $content .= PHP_EOL;
+
+ return [
+ 'title' => 'Missing requirements',
+ 'content' => $content,
+ ];
+ }
+
+ return [
+ 'title' => 'All requirements met',
+ 'content' => $content,
+ ];
+ }
+
+ /**
+ * Check if Docker is available.
+ */
+ protected function checkDocker(): bool {
+ $result = $this->commandExists('docker');
+ if ($result) {
+ $this->present['Docker'] = $this->getCommandVersion('docker --version');
+ }
+ else {
+ $this->missing['Docker'] = 'https://www.docker.com/get-started';
+ }
+ return $result;
+ }
+
+ /**
+ * Check if Docker Compose is available.
+ */
+ protected function checkDockerCompose(): bool {
+ $result = $this->dockerComposeExists();
+ if ($result) {
+ $this->present['Docker Compose'] = $this->getCommandVersion('docker compose version');
+ }
+ else {
+ $this->missing['Docker Compose'] = 'https://docs.docker.com/compose/install/';
+ }
+ return $result;
+ }
+
+ /**
+ * Check if Ahoy is available.
+ */
+ protected function checkAhoy(): bool {
+ $result = $this->commandExists('ahoy');
+ if ($result) {
+ $this->present['Ahoy'] = $this->getCommandVersion('ahoy --version');
+ }
+ else {
+ $this->missing['Ahoy'] = 'https://github.com/ahoy-cli/ahoy';
+ }
+ return $result;
+ }
+
+ /**
+ * Check if Pygmy is running.
+ */
+ protected function checkPygmy(): bool {
+ if (!$this->commandExists('pygmy')) {
+ $this->missing['Pygmy'] = 'Run: pygmy up';
+ return FALSE;
+ }
+
+ $version = $this->getCommandVersion('pygmy version');
+
+ $this->processRunner->run('pygmy status');
+ if ($this->processRunner->getExitCode() === RunnerInterface::EXIT_SUCCESS) {
+ $this->present['Pygmy'] = $version;
+ return TRUE;
+ }
+
+ $this->processRunner->run('docker ps --format "{{.Names}}" | grep -q amazeeio');
+ // @phpstan-ignore-next-line notIdentical.alwaysFalse
+ if ($this->processRunner->getExitCode() === RunnerInterface::EXIT_SUCCESS) {
+ $this->present['Pygmy'] = $version;
+ return TRUE;
+ }
+
+ $this->missing['Pygmy'] = 'Run: pygmy up';
+
+ return FALSE;
+ }
+
+ /**
+ * Check if a command exists.
+ */
+ protected function commandExists(string $command): bool {
+ return $this->getExecutableFinder()->find($command) !== NULL;
+ }
+
+ /**
+ * Check if Docker Compose exists.
+ */
+ protected function dockerComposeExists(): bool {
+ $this->processRunner->run('docker compose version');
+ if ($this->processRunner->getExitCode() === RunnerInterface::EXIT_SUCCESS) {
+ return TRUE;
+ }
+
+ return $this->commandExists('docker-compose');
+ }
+
+ /**
+ * Get command version output.
+ *
+ * @param string $command
+ * The command to run.
+ * @param int $lines
+ * Number of lines to retrieve from the output. Defaults to 1.
+ */
+ protected function getCommandVersion(string $command, int $lines = 1): string {
+ $this->processRunner->run($command);
+ $raw_output = $this->processRunner->getOutput(FALSE, $lines);
+ $output = trim(is_string($raw_output) ? $raw_output : implode(PHP_EOL, $raw_output));
+ return empty($output) ? 'Available' : $output;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getProcessRunner(): ProcessRunner {
+ return $this->processRunner ?? (new ProcessRunner())->disableLog()->disableStreaming();
+ }
+
+}
diff --git a/.vortex/installer/src/Command/InstallCommand.php b/.vortex/installer/src/Command/InstallCommand.php
index 0e2f5a598..1c204ad95 100644
--- a/.vortex/installer/src/Command/InstallCommand.php
+++ b/.vortex/installer/src/Command/InstallCommand.php
@@ -5,15 +5,20 @@
namespace DrevOps\VortexInstaller\Command;
use DrevOps\VortexInstaller\Downloader\Downloader;
+use DrevOps\VortexInstaller\Prompts\Handlers\Starter;
use DrevOps\VortexInstaller\Prompts\PromptManager;
+use DrevOps\VortexInstaller\Runner\CommandRunnerAwareInterface;
+use DrevOps\VortexInstaller\Runner\CommandRunnerAwareTrait;
+use DrevOps\VortexInstaller\Runner\ExecutableFinderAwareInterface;
+use DrevOps\VortexInstaller\Runner\ExecutableFinderAwareTrait;
+use DrevOps\VortexInstaller\Runner\RunnerInterface;
+use DrevOps\VortexInstaller\Task\Task;
use DrevOps\VortexInstaller\Utils\Config;
use DrevOps\VortexInstaller\Utils\Env;
use DrevOps\VortexInstaller\Utils\File;
use DrevOps\VortexInstaller\Utils\Strings;
-use DrevOps\VortexInstaller\Utils\Task;
use DrevOps\VortexInstaller\Utils\Tui;
use Symfony\Component\Console\Command\Command;
-use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
@@ -25,9 +30,12 @@
*
* @package DrevOps\VortexInstaller\Command
*/
-class InstallCommand extends Command {
+class InstallCommand extends Command implements CommandRunnerAwareInterface, ExecutableFinderAwareInterface {
- const ARG_DESTINATION = 'destination';
+ use CommandRunnerAwareTrait;
+ use ExecutableFinderAwareTrait;
+
+ const OPTION_DESTINATION = 'destination';
const OPTION_ROOT = 'root';
@@ -41,6 +49,14 @@ class InstallCommand extends Command {
const OPTION_NO_CLEANUP = 'no-cleanup';
+ const OPTION_BUILD = 'build';
+
+ const BUILD_RESULT_SUCCESS = 'success';
+
+ const BUILD_RESULT_SKIPPED = 'skipped';
+
+ const BUILD_RESULT_FAILED = 'failed';
+
/**
* Defines default command name.
*
@@ -58,44 +74,48 @@ class InstallCommand extends Command {
*/
protected PromptManager $promptManager;
+ /**
+ * The downloader.
+ */
+ protected ?Downloader $downloader = NULL;
+
/**
* {@inheritdoc}
*/
protected function configure(): void {
- $this->setName('Vortex Installer');
+ $this->setName('install');
$this->setDescription('Install Vortex from remote or local repository.');
$this->setHelp(<<addArgument(static::ARG_DESTINATION, InputArgument::OPTIONAL, 'Destination directory. Optional. Defaults to the current directory.');
-
+ $this->addOption(static::OPTION_DESTINATION, NULL, InputOption::VALUE_REQUIRED, 'Destination directory. Defaults to the current directory.');
$this->addOption(static::OPTION_ROOT, NULL, InputOption::VALUE_REQUIRED, 'Path to the root for file path resolution. If not specified, current directory is used.');
$this->addOption(static::OPTION_NO_INTERACTION, 'n', InputOption::VALUE_NONE, 'Do not ask any interactive question.');
$this->addOption(static::OPTION_CONFIG, 'c', InputOption::VALUE_REQUIRED, 'A JSON string with options or a path to a JSON file.');
$this->addOption(static::OPTION_URI, 'l', InputOption::VALUE_REQUIRED, 'Remote or local repository URI with an optional git ref set after @.');
$this->addOption(static::OPTION_NO_CLEANUP, NULL, InputOption::VALUE_NONE, 'Do not remove installer after successful installation.');
+ $this->addOption(static::OPTION_BUILD, 'b', InputOption::VALUE_NONE, 'Run auto-build after installation without prompting.');
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output): int {
- // @see https://github.com/drevops/vortex/issues/1502
- if ($input->getOption('help') || $input->getArgument('destination') == 'help') {
+ if ($input->getOption('help')) {
$output->write($this->getHelp());
return Command::SUCCESS;
@@ -127,7 +147,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
Task::action(
label: 'Downloading Vortex',
action: function (): string {
- $version = (new Downloader())->download($this->config->get(Config::REPO), $this->config->get(Config::REF), $this->config->get(Config::TMP));
+ $version = $this->getDownloader()->download($this->config->get(Config::REPO), $this->config->get(Config::REF), $this->config->get(Config::TMP));
$this->config->set(Config::VERSION, $version);
return $version;
},
@@ -155,7 +175,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
Task::action(
label: 'Preparing demo content',
- action: fn(): string|array => $this->handleDemo(),
+ action: fn(): string | array => $this->prepareDemo(),
success: 'Demo content prepared',
);
}
@@ -168,6 +188,42 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$this->footer();
+ // Should build by default.
+ $should_build = TRUE;
+ // Requested build via `--build` option. Defaults to FALSE.
+ $requested_build = (bool) $this->config->get(Config::BUILD_NOW);
+ // Non-interactive: respect the `--build` option.
+ if ($this->config->getNoInteraction()) {
+ $should_build = $requested_build;
+ }
+ // Interactive: ask only if `--build` option was not provided.
+ elseif (!$requested_build) {
+ $should_build = Tui::confirm(
+ label: 'Run the site build now?',
+ default: (bool) Env::get('VORTEX_INSTALLER_PROMPT_BUILD_NOW', TRUE),
+ hint: 'Takes ~5-10 min; output will be streamed. You can skip and run later with: ahoy build',
+ );
+ }
+
+ if ($should_build) {
+ $build_ok = Task::action(
+ label: 'Building site',
+ action: fn(): bool => $this->runBuildCommand($output),
+ streaming: TRUE,
+ );
+
+ if (!$build_ok) {
+ $this->footerBuildFailed();
+
+ return Command::FAILURE;
+ }
+
+ $this->footerBuildSucceeded();
+ }
+ else {
+ $this->footerBuildSkipped();
+ }
+
// Cleanup should take place only in case of the successful installation.
// Otherwise, the user should be able to re-run the installer.
register_shutdown_function([$this, 'cleanup']);
@@ -176,20 +232,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}
protected function checkRequirements(): void {
- if (passthru('command -v git >/dev/null') === FALSE) {
- throw new \RuntimeException('Missing git.');
- }
-
- if (passthru('command -v curl >/dev/null') === FALSE) {
- throw new \RuntimeException('Missing curl.');
- }
-
- if (passthru('command -v tar >/dev/null') === FALSE) {
- throw new \RuntimeException('Missing tar.');
- }
+ $required_commands = [
+ 'git',
+ 'curl',
+ 'tar',
+ 'composer',
+ ];
- if (passthru('command -v composer >/dev/null') === FALSE) {
- throw new \RuntimeException('Missing Composer.');
+ foreach ($required_commands as $required_command) {
+ if ($this->getExecutableFinder()->find($required_command) === NULL) {
+ throw new \RuntimeException(sprintf('Missing required command: %s.', $required_command));
+ }
}
}
@@ -227,14 +280,20 @@ protected function resolveOptions(array $arguments, array $options): void {
}
// Set destination directory.
- $dst = !empty($arguments['destination']) && is_scalar($arguments[static::ARG_DESTINATION]) ? strval($arguments[static::ARG_DESTINATION]) : NULL;
- $dst = $dst ?: Env::get(Config::DST, $this->config->get(Config::DST, $this->config->get(Config::ROOT)));
+ $dst_from_option = !empty($options[static::OPTION_DESTINATION]) && is_scalar($options[static::OPTION_DESTINATION]) ? strval($options[static::OPTION_DESTINATION]) : NULL;
+ $dst_from_env = Env::get(Config::DST);
+ $dst_from_config = $this->config->get(Config::DST);
+ $dst_from_root = $this->config->get(Config::ROOT);
+
+ $dst = $dst_from_option ?: ($dst_from_env ?: ($dst_from_config ?: $dst_from_root));
$dst = File::realpath($dst);
$this->config->set(Config::DST, $dst, TRUE);
// Load values from the destination .env file, if it exists.
- if (File::exists($this->config->getDst() . '/.env')) {
- Env::putFromDotenv($this->config->getDst() . '/.env');
+ $dest_env_file = $this->config->getDst() . '/.env';
+
+ if (File::exists($dest_env_file)) {
+ Env::putFromDotenv($dest_env_file);
}
[$repo, $ref] = Downloader::parseUri($options[static::OPTION_URI] ?: 'https://github.com/drevops/vortex.git@stable');
@@ -259,6 +318,9 @@ protected function resolveOptions(array $arguments, array $options): void {
// Set no-cleanup flag.
$this->config->set(Config::NO_CLEANUP, (bool) $options[static::OPTION_NO_CLEANUP]);
+
+ // Set build-now flag.
+ $this->config->set(Config::BUILD_NOW, (bool) $options[static::OPTION_BUILD]);
}
protected function prepareDestination(): array {
@@ -327,7 +389,13 @@ protected function copyFiles(): void {
}
}
- protected function handleDemo(): array|string {
+ /**
+ * Prepare demo content if in demo mode.
+ *
+ * @return array|string
+ * Array of messages or a single message.
+ */
+ protected function prepareDemo(): array | string {
if (empty($this->config->get(Config::IS_DEMO))) {
return 'Not a demo mode.';
}
@@ -368,6 +436,26 @@ protected function handleDemo(): array|string {
return $messages;
}
+ /**
+ * Run the 'build' command.
+ *
+ * @param \Symfony\Component\Console\Output\OutputInterface $output
+ * The output interface.
+ *
+ * @return bool
+ * TRUE if the build command succeeded, FALSE otherwise.
+ */
+ protected function runBuildCommand(OutputInterface $output): bool {
+ $responses = $this->promptManager->getResponses();
+ $starter = $responses[Starter::id()] ?? Starter::LOAD_DATABASE_DEMO;
+ $is_profile = in_array($starter, [Starter::INSTALL_PROFILE_CORE, Starter::INSTALL_PROFILE_DRUPALCMS], TRUE);
+
+ $runner = $this->getCommandRunner();
+ $runner->run('build', args: $is_profile ? ['--profile' => '1'] : [], output: $output);
+
+ return $runner->getExitCode() === RunnerInterface::EXIT_SUCCESS;
+ }
+
protected function header(): void {
$logo_large = <<checkRequiredTools();
@@ -481,17 +568,94 @@ public function footer(): void {
foreach ($missing_tools as $tool => $instructions) {
$tools_output .= sprintf(' %s: %s', $tool, $instructions) . PHP_EOL;
}
- $tools_output .= PHP_EOL;
$output .= Strings::wrapLines($tools_output, $prefix);
+ $output .= PHP_EOL;
}
- // Allow post-install handlers to add their messages.
- $output .= Strings::wrapLines($this->promptManager->runPostInstall(), $prefix);
+ $output .= 'Add and commit all files:' . PHP_EOL;
+ $output .= $prefix . 'git add -A' . PHP_EOL;
+ $output .= $prefix . 'git commit -m "Initial commit."' . PHP_EOL;
}
Tui::box($output, $title);
}
+ /**
+ * Display footer after build succeeded.
+ */
+ public function footerBuildSucceeded(): void {
+ $output = '';
+ $prefix = ' ';
+
+ $output .= 'Get site info:' . $prefix . 'ahoy info' . PHP_EOL;
+ $output .= 'Login:' . $prefix . $prefix . $prefix . 'ahoy login' . PHP_EOL;
+ $output .= PHP_EOL;
+
+ $handler_output = $this->promptManager->runPostBuild(self::BUILD_RESULT_SUCCESS);
+ if (!empty($handler_output)) {
+ $output .= $handler_output;
+ }
+
+ Tui::box($output, 'Site is ready');
+ }
+
+ /**
+ * Display footer after build was skipped.
+ */
+ public function footerBuildSkipped(): void {
+ $output = '';
+ $prefix = ' ';
+
+ $responses = $this->promptManager->getResponses();
+ $starter = $responses[Starter::id()] ?? Starter::LOAD_DATABASE_DEMO;
+ $is_profile = in_array($starter, [Starter::INSTALL_PROFILE_CORE, Starter::INSTALL_PROFILE_DRUPALCMS], TRUE);
+
+ $output .= 'Build the site:' . PHP_EOL;
+ if ($is_profile) {
+ $output .= $prefix . 'VORTEX_PROVISION_TYPE=profile ahoy build' . PHP_EOL;
+ }
+ else {
+ $output .= $prefix . 'ahoy build' . PHP_EOL;
+ }
+ $output .= PHP_EOL;
+
+ if ($is_profile) {
+ $output .= 'Export database after build:' . PHP_EOL;
+ $output .= $prefix . 'ahoy export-db db.sql' . PHP_EOL;
+ $output .= PHP_EOL;
+ }
+
+ $handler_output = $this->promptManager->runPostBuild(self::BUILD_RESULT_SKIPPED);
+ if (!empty($handler_output)) {
+ $output .= $handler_output;
+ }
+
+ Tui::box($output, 'Ready to build');
+ }
+
+ /**
+ * Display footer after build failed.
+ */
+ public function footerBuildFailed(): void {
+ $output = '';
+ $prefix = ' ';
+
+ $output .= 'Vortex was installed, but the build process failed.' . PHP_EOL;
+ $output .= PHP_EOL;
+ $output .= 'Troubleshooting:' . PHP_EOL;
+ $output .= $prefix . 'Check logs:' . $prefix . $prefix . 'ahoy logs' . PHP_EOL;
+ $output .= $prefix . 'Retry build:' . $prefix . 'ahoy build' . PHP_EOL;
+ $output .= $prefix . 'Diagnostics:' . $prefix . 'ahoy doctor' . PHP_EOL;
+ $output .= PHP_EOL;
+
+ $handler_output = $this->promptManager->runPostBuild(self::BUILD_RESULT_FAILED);
+ if (!empty($handler_output)) {
+ $output .= $handler_output;
+ }
+
+ Tui::box($output, 'Build encountered errors');
+ }
+
/**
* Check for required development tools.
*
@@ -548,4 +712,27 @@ public function cleanup(): void {
}
}
+ /**
+ * Get the downloader.
+ *
+ * Provides a default Downloader instance or returns the injected one.
+ * This allows tests to inject mocks via setDownloader().
+ *
+ * @return \DrevOps\VortexInstaller\Downloader\Downloader
+ * The downloader.
+ */
+ protected function getDownloader(): Downloader {
+ return $this->downloader ??= new Downloader();
+ }
+
+ /**
+ * Set the downloader.
+ *
+ * @param \DrevOps\VortexInstaller\Downloader\Downloader $downloader
+ * The downloader.
+ */
+ public function setDownloader(Downloader $downloader): void {
+ $this->downloader = $downloader;
+ }
+
}
diff --git a/.vortex/installer/src/Logger/FileLogger.php b/.vortex/installer/src/Logger/FileLogger.php
new file mode 100644
index 000000000..cbe958be1
--- /dev/null
+++ b/.vortex/installer/src/Logger/FileLogger.php
@@ -0,0 +1,164 @@
+enabled) {
+ return FALSE;
+ }
+
+ $name = $this->buildFilename($command, $args);
+ $this->path = $this->getDir() . '/' . static::LOG_DIR . '/' . $name . '-' . date('Y-m-d-His') . '.log';
+
+ $log_dir = dirname($this->path);
+ if (!is_dir($log_dir)) {
+ File::mkdir($log_dir);
+ }
+
+ // @phpstan-ignore-next-line
+ $this->handle = fopen($this->path, 'w');
+
+ return $this->handle !== FALSE;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function write(string $content): void {
+ if ($this->handle !== NULL) {
+ fwrite($this->handle, $content);
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function close(): void {
+ if ($this->handle !== NULL) {
+ fclose($this->handle);
+ $this->handle = NULL;
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getPath(): ?string {
+ return $this->path;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isEnabled(): bool {
+ return $this->enabled;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function enable(): static {
+ $this->enabled = TRUE;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function disable(): static {
+ $this->enabled = FALSE;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setDir(string $dir): static {
+ $this->dir = $dir;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getDir(): string {
+ if ($this->dir === '') {
+ $this->dir = (string) getcwd();
+ }
+
+ return $this->dir;
+ }
+
+ /**
+ * Build log filename from command and arguments.
+ *
+ * @param string $command
+ * The base command.
+ * @param array $args
+ * Command arguments (not options).
+ *
+ * @return string
+ * Sanitized filename suitable for log file.
+ */
+ protected function buildFilename(string $command, array $args = []): string {
+ $parts = [$command];
+
+ // Only include positional arguments, not options (starting with -).
+ foreach ($args as $arg) {
+ if (!str_starts_with($arg, '-')) {
+ $parts[] = $arg;
+ }
+ }
+
+ // Sanitize for use in filename.
+ $name = implode('-', $parts);
+ $name = (string) preg_replace('/[^a-zA-Z0-9\-_]/', '-', $name);
+ $name = (string) preg_replace('/-+/', '-', $name);
+ $name = trim($name, '-');
+
+ return $name !== '' ? $name : 'runner';
+ }
+
+}
diff --git a/.vortex/installer/src/Logger/FileLoggerInterface.php b/.vortex/installer/src/Logger/FileLoggerInterface.php
new file mode 100644
index 000000000..f8c2b6a8a
--- /dev/null
+++ b/.vortex/installer/src/Logger/FileLoggerInterface.php
@@ -0,0 +1,39 @@
+ $args
+ * Command arguments (positional, not options).
+ *
+ * @return bool
+ * TRUE if log was opened, FALSE if logging is disabled.
+ */
+ public function open(string $command, array $args = []): bool;
+
+ /**
+ * Write content to the log.
+ *
+ * @param string $content
+ * Content to write.
+ */
+ public function write(string $content): void;
+
+ /**
+ * Close the log.
+ */
+ public function close(): void;
+
+ /**
+ * Check if logging is enabled.
+ *
+ * @return bool
+ * TRUE if logging is enabled.
+ */
+ public function isEnabled(): bool;
+
+ /**
+ * Enable logging.
+ *
+ * @return static
+ * The logger instance for method chaining.
+ */
+ public function enable(): static;
+
+ /**
+ * Disable logging.
+ *
+ * @return static
+ * The logger instance for method chaining.
+ */
+ public function disable(): static;
+
+}
diff --git a/.vortex/installer/src/Prompts/Handlers/AbstractHandler.php b/.vortex/installer/src/Prompts/Handlers/AbstractHandler.php
index dd2ebef5b..6f843ec68 100644
--- a/.vortex/installer/src/Prompts/Handlers/AbstractHandler.php
+++ b/.vortex/installer/src/Prompts/Handlers/AbstractHandler.php
@@ -170,6 +170,13 @@ public function postInstall(): ?string {
return NULL;
}
+ /**
+ * {@inheritdoc}
+ */
+ public function postBuild(string $result): ?string {
+ return NULL;
+ }
+
/**
* Check that Vortex is installed for this project.
*
diff --git a/.vortex/installer/src/Prompts/Handlers/CiProvider.php b/.vortex/installer/src/Prompts/Handlers/CiProvider.php
index fb5e069b3..b05364887 100644
--- a/.vortex/installer/src/Prompts/Handlers/CiProvider.php
+++ b/.vortex/installer/src/Prompts/Handlers/CiProvider.php
@@ -119,4 +119,36 @@ public function process(): void {
}
}
+ /**
+ * {@inheritdoc}
+ */
+ public function postInstall(): ?string {
+ return NULL;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function postBuild(string $result): ?string {
+ if ($this->isInstalled()) {
+ return NULL;
+ }
+
+ $v = $this->getResponseAsString();
+
+ if ($v === self::GITHUB_ACTIONS) {
+ return 'Setup GitHub Actions:' . PHP_EOL
+ . ' https://www.vortextemplate.com/docs/continuous-integration/github-actions#onboarding' . PHP_EOL
+ . PHP_EOL;
+ }
+
+ if ($v === self::CIRCLECI) {
+ return 'Setup CircleCI:' . PHP_EOL
+ . ' https://www.vortextemplate.com/docs/continuous-integration/circleci#onboarding' . PHP_EOL
+ . PHP_EOL;
+ }
+
+ return NULL;
+ }
+
}
diff --git a/.vortex/installer/src/Prompts/Handlers/HandlerInterface.php b/.vortex/installer/src/Prompts/Handlers/HandlerInterface.php
index cc4f4dcd9..593c6e7d4 100644
--- a/.vortex/installer/src/Prompts/Handlers/HandlerInterface.php
+++ b/.vortex/installer/src/Prompts/Handlers/HandlerInterface.php
@@ -184,4 +184,15 @@ public function process(): void;
*/
public function postInstall(): ?string;
+ /**
+ * Actions to perform and messages to print after build is complete.
+ *
+ * @param string $result
+ * The result of the build operation.
+ *
+ * @return string|null
+ * The output to display, or NULL if none.
+ */
+ public function postBuild(string $result): ?string;
+
}
diff --git a/.vortex/installer/src/Prompts/Handlers/HostingProvider.php b/.vortex/installer/src/Prompts/Handlers/HostingProvider.php
index c01d521eb..ba02b46dd 100644
--- a/.vortex/installer/src/Prompts/Handlers/HostingProvider.php
+++ b/.vortex/installer/src/Prompts/Handlers/HostingProvider.php
@@ -127,4 +127,36 @@ protected function removeLagoon(): void {
File::removeTokenAsync('SETTINGS_PROVIDER_LAGOON');
}
+ /**
+ * {@inheritdoc}
+ */
+ public function postInstall(): ?string {
+ return NULL;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function postBuild(string $result): ?string {
+ if ($this->isInstalled()) {
+ return NULL;
+ }
+
+ $v = $this->getResponseAsString();
+
+ if ($v === self::ACQUIA) {
+ return 'Setup Acquia hosting:' . PHP_EOL
+ . ' https://www.vortextemplate.com/docs/hosting/acquia#onboarding' . PHP_EOL
+ . PHP_EOL;
+ }
+
+ if ($v === self::LAGOON) {
+ return 'Setup Lagoon hosting:' . PHP_EOL
+ . ' https://www.vortextemplate.com/docs/hosting/lagoon#onboarding' . PHP_EOL
+ . PHP_EOL;
+ }
+
+ return NULL;
+ }
+
}
diff --git a/.vortex/installer/src/Prompts/Handlers/Internal.php b/.vortex/installer/src/Prompts/Handlers/Internal.php
index ba4c6f20c..efa2fd6e8 100644
--- a/.vortex/installer/src/Prompts/Handlers/Internal.php
+++ b/.vortex/installer/src/Prompts/Handlers/Internal.php
@@ -166,7 +166,10 @@ protected function processDemoMode(array $responses, string $dir): void {
$this->config->set(Config::IS_DEMO, $is_demo);
}
- public function postInstall():?string {
+ /**
+ * {@inheritdoc}
+ */
+ public function postInstall(): ?string {
$output = '';
if (!$this->isInstalled()) {
diff --git a/.vortex/installer/src/Prompts/Handlers/Starter.php b/.vortex/installer/src/Prompts/Handlers/Starter.php
index d6b85852d..e632d2961 100644
--- a/.vortex/installer/src/Prompts/Handlers/Starter.php
+++ b/.vortex/installer/src/Prompts/Handlers/Starter.php
@@ -132,35 +132,4 @@ public function process(): void {
}
}
- /**
- * {@inheritdoc}
- */
- public function postInstall(): ?string {
- if ($this->isInstalled()) {
- return NULL;
- }
-
- $output = '';
-
- if ($this->response == self::LOAD_DATABASE_DEMO) {
- $output .= 'Build project locally:' . PHP_EOL;
- $output .= ' ahoy build' . PHP_EOL;
- $output .= PHP_EOL;
- }
- elseif ($this->response == self::INSTALL_PROFILE_CORE || $this->response == self::INSTALL_PROFILE_DRUPALCMS) {
- $output .= 'Build project locally:' . PHP_EOL;
- $output .= ' VORTEX_PROVISION_TYPE=profile ahoy build' . PHP_EOL;
- $output .= PHP_EOL;
- $output .= 'Export database:' . PHP_EOL;
- $output .= ' ahoy export-db db.sql' . PHP_EOL;
- $output .= PHP_EOL;
- }
-
- // @todo Update to use separate steps for hosting and CI/CD configuration.
- $output .= 'Setup integration with your hosting and CI/CD providers:' . PHP_EOL;
- $output .= ' See https://www.vortextemplate.com/docs/getting-started/installation';
-
- return $output . PHP_EOL;
- }
-
}
diff --git a/.vortex/installer/src/Prompts/PromptManager.php b/.vortex/installer/src/Prompts/PromptManager.php
index c16e44f80..a4eae4412 100644
--- a/.vortex/installer/src/Prompts/PromptManager.php
+++ b/.vortex/installer/src/Prompts/PromptManager.php
@@ -324,8 +324,10 @@ public function runPostInstall(): string {
$output = '';
$ids = [
- Internal::id(),
Starter::id(),
+ HostingProvider::id(),
+ CiProvider::id(),
+ Internal::id(),
];
foreach ($ids as $id) {
@@ -343,6 +345,39 @@ public function runPostInstall(): string {
return $output;
}
+ /**
+ * Run all post-build processors.
+ *
+ * @param string $result
+ * The result of the build operation.
+ *
+ * @return string
+ * The combined output from all post-build processors.
+ */
+ public function runPostBuild(string $result): string {
+ $output = '';
+
+ $ids = [
+ Starter::id(),
+ HostingProvider::id(),
+ CiProvider::id(),
+ ];
+
+ foreach ($ids as $id) {
+ if (!array_key_exists($id, $this->handlers)) {
+ throw new \RuntimeException(sprintf('Handler for "%s" not found.', $id));
+ }
+
+ $handler_output = $this->handlers[$id]->postBuild($result);
+
+ if (is_string($handler_output) && !empty($handler_output)) {
+ $output .= $handler_output;
+ }
+ }
+
+ return $output;
+ }
+
/**
* Check if the installation should proceed.
*
diff --git a/.vortex/installer/src/Runner/AbstractRunner.php b/.vortex/installer/src/Runner/AbstractRunner.php
new file mode 100644
index 000000000..e6a067909
--- /dev/null
+++ b/.vortex/installer/src/Runner/AbstractRunner.php
@@ -0,0 +1,409 @@
+
+ */
+ protected int $exitCode = 0;
+
+ /**
+ * The output from the last run.
+ */
+ protected string $output = '';
+
+ /**
+ * The working directory.
+ */
+ protected string $cwd = '';
+
+ /**
+ * The logger instance.
+ */
+ protected FileLoggerInterface $logger;
+
+ /**
+ * Whether to stream output to console.
+ */
+ protected bool $shouldStream = TRUE;
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getLogger(): FileLoggerInterface {
+ if (!isset($this->logger)) {
+ $this->logger = new FileLogger();
+ }
+
+ return $this->logger;
+ }
+
+ /**
+ * Initialize the logger for a command execution.
+ *
+ * Sets the logger directory and opens a log file for the command.
+ *
+ * @param string $command
+ * The command name for the log filename.
+ * @param array $args
+ * Positional arguments to include in the log filename.
+ *
+ * @return \DrevOps\VortexInstaller\Logger\FileLoggerInterface
+ * The initialized logger instance.
+ */
+ protected function initLogger(string $command, array $args = []): FileLoggerInterface {
+ $logger = $this->getLogger();
+ $logger->setDir($this->getCwd());
+ $logger->open($command, $args);
+
+ return $logger;
+ }
+
+ /**
+ * Resolve the output interface, defaulting to ConsoleOutput.
+ *
+ * @param \Symfony\Component\Console\Output\OutputInterface|null $output
+ * The output interface or NULL to use default.
+ *
+ * @return \Symfony\Component\Console\Output\OutputInterface
+ * The resolved output interface.
+ */
+ protected function resolveOutput(?OutputInterface $output): OutputInterface {
+ return $output ?? Tui::output();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setCwd(string $cwd): static {
+ $this->cwd = $cwd;
+ $this->getLogger()->setDir($cwd);
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCwd(): string {
+ if ($this->cwd === '') {
+ $cwd = getcwd();
+ if ($cwd === FALSE) {
+ throw new \RuntimeException('Unable to determine current working directory.');
+ }
+ $this->cwd = $cwd;
+ $this->getLogger()->setDir($this->cwd);
+ }
+
+ return $this->cwd;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function enableLog(): static {
+ $this->getLogger()->enable();
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function disableLog(): static {
+ $this->getLogger()->disable();
+
+ return $this;
+ }
+
+ /**
+ * Enable streaming output to console.
+ *
+ * @return static
+ * The runner instance for method chaining.
+ */
+ public function enableStreaming(): static {
+ $this->shouldStream = TRUE;
+
+ return $this;
+ }
+
+ /**
+ * Disable streaming output to console.
+ *
+ * When disabled, output is still captured but not written to console.
+ *
+ * @return static
+ * The runner instance for method chaining.
+ */
+ public function disableStreaming(): static {
+ $this->shouldStream = FALSE;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getCommand(): ?string {
+ return $this->command;
+ }
+
+ /**
+ * Get the exit code from the last run.
+ *
+ * @return int<0, 255>
+ * The exit code.
+ */
+ public function getExitCode(): int {
+ if ($this->exitCode < 0 || $this->exitCode > 255) {
+ throw new \RuntimeException(sprintf('Exit code %d is out of valid range (0-255).', $this->exitCode));
+ }
+
+ return $this->exitCode;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getOutput(bool $as_array = FALSE, ?int $lines = NULL): string | array {
+ $output_lines = explode(PHP_EOL, $this->output);
+
+ if ($lines !== NULL) {
+ $output_lines = array_slice($output_lines, 0, $lines);
+ }
+
+ if ($as_array) {
+ return $output_lines;
+ }
+
+ return implode(PHP_EOL, $output_lines);
+ }
+
+ /**
+ * Reset state before a new run.
+ */
+ protected function reset(): void {
+ $this->command = NULL;
+ $this->output = '';
+ $this->exitCode = RunnerInterface::EXIT_SUCCESS;
+ }
+
+ /**
+ * Parse a command string into an array of parts.
+ *
+ * Handles quoted arguments and escaping properly. Supports both single
+ * and double quotes. Also supports the end-of-options marker (--) which
+ * stops option parsing and treats all subsequent tokens as positional
+ * arguments.
+ *
+ * Note: This parser intentionally allows backslash escaping inside single
+ * quotes (e.g., 'It\'s working'), which deviates from POSIX shell behavior
+ * where backslashes are literal inside single quotes. This provides more
+ * intuitive escaping for users.
+ *
+ * @param string $command
+ * The command string to parse.
+ *
+ * @return array
+ * Array with command as first element and arguments as subsequent elements.
+ */
+ protected function parseCommand(string $command): array {
+ $command = trim($command);
+ if (empty($command)) {
+ throw new \InvalidArgumentException('Command cannot be empty.');
+ }
+
+ $parts = [];
+ $current = '';
+ $in_quotes = FALSE;
+ $quote_char = '';
+ $escaped = FALSE;
+ $length = strlen($command);
+ $has_content = FALSE;
+ $end_of_options_found = FALSE;
+
+ for ($i = 0; $i < $length; $i++) {
+ $char = $command[$i];
+
+ if ($escaped) {
+ $current .= $char;
+ $escaped = FALSE;
+ $has_content = TRUE;
+ continue;
+ }
+
+ if ($char === '\\') {
+ $escaped = TRUE;
+ continue;
+ }
+
+ if (!$in_quotes && ($char === '"' || $char === "'")) {
+ $in_quotes = TRUE;
+ $quote_char = $char;
+ $has_content = TRUE;
+ continue;
+ }
+
+ if ($in_quotes && $char === $quote_char) {
+ $in_quotes = FALSE;
+ $quote_char = '';
+ continue;
+ }
+
+ if (!$in_quotes && ($char === ' ' || $char === "\t")) {
+ if ($current !== '' || $has_content) {
+ // Check for end-of-options marker (--) only if not already found
+ // and not inside quotes.
+ if (!$end_of_options_found && $current === '--') {
+ $end_of_options_found = TRUE;
+ // Add the -- marker to the parts array so it reaches the command.
+ $parts[] = $current;
+ $current = '';
+ $has_content = FALSE;
+ continue;
+ }
+
+ $parts[] = $current;
+ $current = '';
+ $has_content = FALSE;
+ }
+ continue;
+ }
+
+ $current .= $char;
+ $has_content = TRUE;
+ }
+
+ if ($in_quotes) {
+ throw new \InvalidArgumentException('Unclosed quote in command string.');
+ }
+
+ if ($escaped) {
+ throw new \InvalidArgumentException('Trailing escape character in command string.');
+ }
+
+ if ($current !== '' || $has_content) {
+ $parts[] = $current;
+ }
+
+ return $parts;
+ }
+
+ /**
+ * Build a command string for display or logging.
+ *
+ * Produces a shell-safe command string that can be copy-pasted.
+ * Arguments containing spaces or special characters are properly quoted.
+ *
+ * @param string $command
+ * The base command.
+ * @param array $args
+ * Command arguments.
+ * @param array $opts
+ * Command options.
+ *
+ * @return string
+ * The formatted command string.
+ */
+ protected function buildCommandString(string $command, array $args = [], array $opts = []): string {
+ $parts = [$command];
+
+ $formatted_args = $this->formatArgs($args);
+ $formatted_opts = $this->formatArgs($opts);
+
+ foreach ($formatted_args as $formatted_arg) {
+ $parts[] = $this->quoteArgument($formatted_arg);
+ }
+
+ foreach ($formatted_opts as $formatted_opt) {
+ $parts[] = $this->quoteArgument($formatted_opt);
+ }
+
+ return implode(' ', $parts);
+ }
+
+ /**
+ * Quote an argument if it contains special characters.
+ *
+ * @param string $argument
+ * The argument to quote.
+ *
+ * @return string
+ * The quoted argument if needed, otherwise the original.
+ */
+ protected function quoteArgument(string $argument): string {
+ // If argument is empty, return empty quoted string.
+ if ($argument === '') {
+ return "''";
+ }
+
+ // Check if argument needs quoting (contains spaces, quotes, or shell
+ // special chars).
+ if (preg_match('/[\s"\'\\\\$`!*?#~<>|;&(){}[\]]/', $argument)) {
+ // Use single quotes and escape any single quotes within.
+ $escaped = str_replace("'", "'\\''", $argument);
+ return "'" . $escaped . "'";
+ }
+
+ return $argument;
+ }
+
+ /**
+ * Format arguments for display or logging.
+ *
+ * @param array $args
+ * The arguments to format.
+ *
+ * @return array
+ * Formatted arguments as strings.
+ */
+ protected function formatArgs(array $args): array {
+ $formatted = [];
+
+ foreach ($args as $key => $value) {
+ if (is_int($key)) {
+ // Positional argument.
+ if (is_bool($value)) {
+ if ($value) {
+ $formatted[] = '1';
+ }
+ }
+ else {
+ $formatted[] = (string) $value;
+ }
+ }
+ elseif (is_bool($value)) {
+ // Named argument/option.
+ if ($value) {
+ $formatted[] = $key;
+ }
+ }
+ else {
+ $formatted[] = $key . '=' . $value;
+ }
+ }
+
+ return $formatted;
+ }
+
+}
diff --git a/.vortex/installer/src/Runner/CommandRunner.php b/.vortex/installer/src/Runner/CommandRunner.php
new file mode 100644
index 000000000..6651f486d
--- /dev/null
+++ b/.vortex/installer/src/Runner/CommandRunner.php
@@ -0,0 +1,146 @@
+reset();
+
+ // Merge args and inputs (options) for ArrayInput.
+ $input_args = array_merge($args, $inputs);
+ $this->command = $this->buildCommandString($command, $args, $inputs);
+
+ // Validate command existence and prepare input (also validated).
+ $symfony_command = $this->application->find($command);
+ $input = new ArrayInput($input_args);
+
+ $positional_args = array_values(array_filter($args, fn($key): bool => is_int($key), ARRAY_FILTER_USE_KEY));
+ $logger = $this->initLogger($command, $positional_args);
+
+ $output = $this->resolveOutput($output);
+
+ // Create composite output that captures, streams, and logs.
+ [$composite_output, $buffered_output] = $this->createCompositeOutput($output, $logger);
+
+ $exit_code = $symfony_command->run($input, $composite_output);
+
+ if ($exit_code < 0 || $exit_code > 255) {
+ throw new \RuntimeException('Command exited with invalid exit code: ' . $exit_code);
+ }
+
+ match ($exit_code) {
+ Command::SUCCESS => $this->exitCode = self::EXIT_SUCCESS,
+ Command::FAILURE => $this->exitCode = self::EXIT_FAILURE,
+ 127 => $this->exitCode = self::EXIT_COMMAND_NOT_FOUND,
+ default => $this->exitCode = self::EXIT_INVALID,
+ };
+
+ $this->exitCode = $exit_code;
+ $this->output = $buffered_output->fetch();
+
+ $logger->close();
+
+ return $this;
+ }
+
+ /**
+ * Create a composite output that captures, streams, and logs.
+ *
+ * @param \Symfony\Component\Console\Output\OutputInterface $output
+ * The output interface to stream to.
+ * @param \DrevOps\VortexInstaller\Logger\LoggerInterface $logger
+ * The logger to write to.
+ *
+ * @return array{0: \Symfony\Component\Console\Output\OutputInterface, 1: \Symfony\Component\Console\Output\BufferedOutput}
+ * Array with [composite_output, buffered_output].
+ */
+ protected function createCompositeOutput(OutputInterface $output, LoggerInterface $logger): array {
+ $buffered_output = new BufferedOutput();
+
+ $composite_output = new class($buffered_output, $output, $logger, $this->shouldStream) extends BufferedOutput {
+
+ public function __construct(
+ private readonly BufferedOutput $bufferedOutput,
+ private readonly OutputInterface $output,
+ private readonly LoggerInterface $logger,
+ private readonly bool $shouldStream,
+ ) {
+ parent::__construct();
+ }
+
+ /**
+ * Write a message to the output and log.
+ *
+ * @param string|iterable $messages
+ * The message or messages to write.
+ * @param bool $newline
+ * Whether to add a newline after the message.
+ * @param int $options
+ * Write options.
+ */
+ public function write(string | iterable $messages, bool $newline = FALSE, int $options = 0): void {
+ $this->bufferedOutput->write($messages, $newline, $options);
+
+ if ($this->shouldStream) {
+ $this->output->write($messages, $newline, $options);
+ }
+
+ $text = is_iterable($messages) ? implode($newline ? PHP_EOL : '', (array) $messages) : $messages;
+ $this->logger->write($text . ($newline ? PHP_EOL : ''));
+ }
+
+ /**
+ * Write a message with a newline to the output and log.
+ *
+ * @param string|iterable $messages
+ * The message or messages to write.
+ * @param int $options
+ * Write options.
+ */
+ public function writeln(string | iterable $messages, int $options = 0): void {
+ $this->bufferedOutput->writeln($messages, $options);
+ if ($this->shouldStream) {
+ $this->output->writeln($messages, $options);
+ }
+ $text = is_iterable($messages) ? implode(PHP_EOL, (array) $messages) : $messages;
+ $this->logger->write($text . PHP_EOL);
+ }
+
+ public function fetch(): string {
+ return $this->bufferedOutput->fetch();
+ }
+
+ };
+
+ return [$composite_output, $buffered_output];
+ }
+
+}
diff --git a/.vortex/installer/src/Runner/CommandRunnerAwareInterface.php b/.vortex/installer/src/Runner/CommandRunnerAwareInterface.php
new file mode 100644
index 000000000..589d40b2b
--- /dev/null
+++ b/.vortex/installer/src/Runner/CommandRunnerAwareInterface.php
@@ -0,0 +1,28 @@
+commandRunner ??= new CommandRunner($this->getApplication());
+ }
+
+ /**
+ * Set the command runner.
+ *
+ * Allows dependency injection for testing.
+ *
+ * @param \DrevOps\VortexInstaller\Runner\CommandRunner $runner
+ * The command runner instance.
+ */
+ public function setCommandRunner(CommandRunner $runner): void {
+ $this->commandRunner = $runner;
+ }
+
+}
diff --git a/.vortex/installer/src/Runner/ExecutableFinderAwareInterface.php b/.vortex/installer/src/Runner/ExecutableFinderAwareInterface.php
new file mode 100644
index 000000000..fb6f1f5af
--- /dev/null
+++ b/.vortex/installer/src/Runner/ExecutableFinderAwareInterface.php
@@ -0,0 +1,30 @@
+executableFinder === NULL) {
+ $this->executableFinder = new ExecutableFinder();
+ }
+ return $this->executableFinder;
+ }
+
+ /**
+ * Set the executable finder.
+ *
+ * Allows dependency injection for testing.
+ *
+ * @param \Symfony\Component\Process\ExecutableFinder $finder
+ * The executable finder instance.
+ */
+ public function setExecutableFinder(ExecutableFinder $finder): void {
+ $this->executableFinder = $finder;
+ }
+
+}
diff --git a/.vortex/installer/src/Runner/ProcessRunner.php b/.vortex/installer/src/Runner/ProcessRunner.php
new file mode 100644
index 000000000..a8937c501
--- /dev/null
+++ b/.vortex/installer/src/Runner/ProcessRunner.php
@@ -0,0 +1,174 @@
+reset();
+
+ // Parse and resolve the command.
+ [$base_command, $parsed_args] = $this->resolveCommand($command);
+
+ $all_args = $this->prepareArguments($parsed_args, $args);
+
+ $this->validateEnvironmentVars($env);
+
+ // Build full command array.
+ $cmd = array_merge([$base_command], $all_args);
+
+ // Store command string for logging with proper quoting.
+ $this->command = $this->buildCommandString($base_command, $all_args);
+
+ $logger = $this->initLogger($base_command, $parsed_args);
+ $output = $this->resolveOutput($output);
+
+ // Prepare inputs for interactive processes.
+ $input_string = empty($inputs) ? NULL : implode(PHP_EOL, $inputs) . PHP_EOL;
+
+ $process = new Process($cmd, $this->getCwd(), $env ?: NULL, $input_string);
+ $process->setTimeout(NULL);
+ $process->setIdleTimeout(NULL);
+
+ $process->run(function ($type, string|iterable $buffer) use ($logger, $output): void {
+ $buffer = is_iterable($buffer) ? implode("\n", (array) $buffer) : $buffer;
+ $this->output = $buffer;
+ if ($this->shouldStream) {
+ $output->write($buffer);
+ }
+ $logger->write($buffer);
+ });
+
+ $logger->close();
+
+ $exit_code = $process->getExitCode();
+
+ if ($exit_code < 0 || $exit_code > 255) {
+ throw new \RuntimeException('Command exited with invalid exit code: ' . $exit_code);
+ }
+
+ match ($exit_code) {
+ Command::SUCCESS => $this->exitCode = self::EXIT_SUCCESS,
+ Command::FAILURE => $this->exitCode = self::EXIT_FAILURE,
+ 127 => $this->exitCode = self::EXIT_COMMAND_NOT_FOUND,
+ default => $this->exitCode = self::EXIT_INVALID,
+ };
+
+ $this->exitCode = $exit_code;
+
+ return $this;
+ }
+
+ /**
+ * Parse and resolve the command, validating it exists.
+ *
+ * @param string $command
+ * The command string to parse.
+ *
+ * @return array{0: string, 1: array}
+ * Array with [resolved_command_path, parsed_arguments].
+ *
+ * @throws \InvalidArgumentException
+ * When command contains invalid characters or cannot be found.
+ */
+ protected function resolveCommand(string $command): array {
+ $parsed = $this->parseCommand($command);
+ $base_command = array_shift($parsed);
+
+ // Defensive check: prevent using 'command' utility.
+ if ($base_command === 'command') {
+ throw new \InvalidArgumentException('Using the "command" utility is not allowed. Use Symfony\Component\Process\ExecutableFinder to check if a command exists instead.');
+ }
+
+ // Validate the base command contains only allowed characters.
+ if (preg_match('/[^a-zA-Z0-9_\-.\/]/', (string) $base_command)) {
+ throw new \InvalidArgumentException(sprintf('Invalid command: %s. Only alphanumeric characters, dots, dashes, underscores and slashes are allowed.', $base_command));
+ }
+
+ // If command is a path (contains /), check if it exists directly.
+ if (str_contains((string) $base_command, '/')) {
+ $resolved = $base_command;
+ // Check relative to cwd if not absolute.
+ if (!str_starts_with((string) $base_command, '/')) {
+ $full_path = $this->getCwd() . '/' . $base_command;
+ if (is_executable($full_path)) {
+ $resolved = $full_path;
+ }
+ }
+ }
+ else {
+ // Use ExecutableFinder for commands without path.
+ $resolved = $this->getExecutableFinder()->find($base_command);
+
+ if ($resolved === NULL) {
+ throw new \InvalidArgumentException(sprintf('Command not found: %s. Ensure the command is installed and available in PATH.', $base_command));
+ }
+ }
+
+ return [$resolved, $parsed];
+ }
+
+ /**
+ * Prepare arguments by merging and validating them.
+ *
+ * @param array $parsed_args
+ * Arguments parsed from the command string.
+ * @param array $additional_args
+ * Additional arguments passed to run().
+ *
+ * @return array
+ * Merged and validated arguments as strings.
+ *
+ * @throws \InvalidArgumentException
+ * When an argument is not a scalar value.
+ */
+ protected function prepareArguments(array $parsed_args, array $additional_args): array {
+ $all_args = array_merge($parsed_args, $this->formatArgs($additional_args));
+
+ foreach ($all_args as $key => &$arg) {
+ if (!is_scalar($arg)) {
+ $value_repr = get_debug_type($arg);
+ throw new \InvalidArgumentException(sprintf('Argument at index "%s" must be a scalar value, %s given.', $key, $value_repr));
+ }
+ $arg = (string) $arg;
+ }
+ unset($arg);
+
+ return $all_args;
+ }
+
+ /**
+ * Validate environment variables are scalar values.
+ *
+ * @param array $env
+ * Environment variables to validate.
+ *
+ * @throws \InvalidArgumentException
+ * When an environment variable is not a scalar value.
+ */
+ protected function validateEnvironmentVars(array $env): void {
+ foreach ($env as $key => $env_value) {
+ if (!is_scalar($env_value)) {
+ $value_repr = get_debug_type($env_value);
+ throw new \InvalidArgumentException(sprintf('Environment variable "%s" must be a scalar value, %s given.', $key, $value_repr));
+ }
+ }
+ }
+
+}
diff --git a/.vortex/installer/src/Runner/ProcessRunnerAwareInterface.php b/.vortex/installer/src/Runner/ProcessRunnerAwareInterface.php
new file mode 100644
index 000000000..ea114b965
--- /dev/null
+++ b/.vortex/installer/src/Runner/ProcessRunnerAwareInterface.php
@@ -0,0 +1,28 @@
+processRunner ??= new ProcessRunner();
+ }
+
+ /**
+ * Set the process runner.
+ *
+ * Allows dependency injection for testing.
+ *
+ * @param \DrevOps\VortexInstaller\Runner\ProcessRunner $runner
+ * The process runner instance.
+ */
+ public function setProcessRunner(ProcessRunner $runner): void {
+ $this->processRunner = $runner;
+ }
+
+}
diff --git a/.vortex/installer/src/Runner/RunnerInterface.php b/.vortex/installer/src/Runner/RunnerInterface.php
new file mode 100644
index 000000000..f2f0503d0
--- /dev/null
+++ b/.vortex/installer/src/Runner/RunnerInterface.php
@@ -0,0 +1,88 @@
+ $args
+ * Additional command arguments.
+ * @param array $inputs
+ * Interactive inputs for the command.
+ * @param array $env
+ * Environment variables.
+ * @param \Symfony\Component\Console\Output\OutputInterface|null $output
+ * Output interface. Defaults to STDOUT if NULL.
+ *
+ * @return self
+ * The runner instance for method chaining.
+ */
+ public function run(string $command, array $args = [], array $inputs = [], array $env = [], ?OutputInterface $output = NULL): self;
+
+ /**
+ * Get the last command that was run.
+ */
+ public function getCommand(): ?string;
+
+ /**
+ * Get the exit code from the last run.
+ *
+ * @return int<0, 255>
+ * The exit code.
+ */
+ public function getExitCode(): int;
+
+ /**
+ * Get the output from the last run.
+ *
+ * @param bool $as_array
+ * Whether to return output as array of lines. Defaults to FALSE.
+ * @param int|null $lines
+ * Number of lines to return. NULL returns all lines.
+ *
+ * @return string|array
+ * Output as string or array of lines.
+ */
+ public function getOutput(bool $as_array = FALSE, ?int $lines = NULL): string | array;
+
+ /**
+ * Set the working directory.
+ *
+ * @param string $cwd
+ * The working directory path.
+ *
+ * @return static
+ * The runner instance for method chaining.
+ */
+ public function setCwd(string $cwd): static;
+
+ /**
+ * Get the working directory.
+ *
+ * @return string
+ * The working directory path.
+ */
+ public function getCwd(): string;
+
+}
diff --git a/.vortex/installer/src/Utils/Task.php b/.vortex/installer/src/Task/Task.php
similarity index 66%
rename from .vortex/installer/src/Utils/Task.php
rename to .vortex/installer/src/Task/Task.php
index 13c7f6537..6d09c317a 100644
--- a/.vortex/installer/src/Utils/Task.php
+++ b/.vortex/installer/src/Task/Task.php
@@ -2,8 +2,10 @@
declare(strict_types=1);
-namespace DrevOps\VortexInstaller\Utils;
+namespace DrevOps\VortexInstaller\Task;
+use DrevOps\VortexInstaller\Utils\Strings;
+use DrevOps\VortexInstaller\Utils\Tui;
use function Laravel\Prompts\spin;
class Task {
@@ -18,7 +20,8 @@ public static function action(
\Closure|string|null $hint = NULL,
\Closure|string|null $success = NULL,
\Closure|string|null $failure = NULL,
- ): void {
+ bool $streaming = FALSE,
+ ): mixed {
$label = is_callable($label) ? $label() : $label;
if (!is_callable($action)) {
@@ -27,10 +30,30 @@ public static function action(
$label = Tui::normalizeText($label);
- // @phpstan-ignore-next-line
- $return = spin($action, Tui::yellow($label));
+ if ($streaming) {
+ $original_output = Tui::output();
- self::label($label, $hint && is_callable($hint) ? $hint() : $hint, is_array($return) ? $return : NULL, Strings::isAsciiStart($label) ? 2 : 3);
+ $task_output = new TaskOutput($original_output);
+
+ static::start($label);
+
+ Tui::setOutput($task_output);
+
+ ob_start(function (string|iterable $buffer) use ($task_output): string {
+ $task_output->write($buffer);
+ return '';
+ }, 1);
+
+ $return = $action();
+
+ ob_end_clean();
+ Tui::setOutput($original_output);
+ }
+ else {
+ // @phpstan-ignore-next-line
+ $return = spin($action, Tui::yellow($label));
+ self::label($label, $hint && is_callable($hint) ? $hint() : $hint, is_array($return) ? $return : NULL, Strings::isAsciiStart($label) ? 2 : 3);
+ }
if ($return === FALSE) {
$failure = $failure && is_callable($failure) ? $failure() : $failure;
@@ -40,6 +63,8 @@ public static function action(
$success = $success && is_callable($success) ? $success($return) : $success;
static::ok($success ? Tui::normalizeText($success) : 'OK');
}
+
+ return $return;
}
protected static function label(string $message, ?string $hint = NULL, ?array $sublist = NULL, int $sublist_indent = 3): void {
@@ -74,4 +99,9 @@ protected static function ok(string $text = 'OK'): void {
Tui::note(str_repeat(Tui::caretUp(), 4));
}
+ protected static function start(string $label): void {
+ $message = '✦ ' . $label;
+ Tui::line(Tui::blue(Tui::normalizeText($message)));
+ }
+
}
diff --git a/.vortex/installer/src/Task/TaskOutput.php b/.vortex/installer/src/Task/TaskOutput.php
new file mode 100644
index 000000000..bde86539d
--- /dev/null
+++ b/.vortex/installer/src/Task/TaskOutput.php
@@ -0,0 +1,123 @@
+ $messages
+ * The message or messages to write.
+ * @param bool $newline
+ * Whether to add a newline after the message.
+ * @param int $options
+ * Write options.
+ */
+ public function write(string | iterable $messages, bool $newline = FALSE, int $options = 0): void {
+ $dimmed = is_iterable($messages)
+ ? array_map(fn(string $m): string => Tui::dim($m), (array) $messages)
+ : Tui::dim($messages);
+ $this->wrapped->write($dimmed, $newline, $options);
+ }
+
+ /**
+ * Writes a message to the output and adds a newline at the end.
+ *
+ * @param string|iterable $messages
+ * The message or messages to write.
+ * @param int $options
+ * Write options.
+ */
+ public function writeln(string | iterable $messages, int $options = 0): void {
+ $dimmed = is_iterable($messages)
+ ? array_map(fn(string $m): string => Tui::dim($m), (array) $messages)
+ : Tui::dim($messages);
+ $this->wrapped->writeln($dimmed, $options);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setVerbosity(int $level): void {
+ $this->wrapped->setVerbosity($level);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getVerbosity(): int {
+ return $this->wrapped->getVerbosity();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isQuiet(): bool {
+ return $this->wrapped->isQuiet();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isVerbose(): bool {
+ return $this->wrapped->isVerbose();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isVeryVerbose(): bool {
+ return $this->wrapped->isVeryVerbose();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isDebug(): bool {
+ return $this->wrapped->isDebug();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setDecorated(bool $decorated): void {
+ $this->wrapped->setDecorated($decorated);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function isDecorated(): bool {
+ return $this->wrapped->isDecorated();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setFormatter(OutputFormatterInterface $formatter): void {
+ $this->wrapped->setFormatter($formatter);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getFormatter(): OutputFormatterInterface {
+ return $this->wrapped->getFormatter();
+ }
+
+}
diff --git a/.vortex/installer/src/Utils/Config.php b/.vortex/installer/src/Utils/Config.php
index b81c8fa52..eaf68e48d 100644
--- a/.vortex/installer/src/Utils/Config.php
+++ b/.vortex/installer/src/Utils/Config.php
@@ -39,6 +39,8 @@ final class Config {
const string NO_CLEANUP = 'VORTEX_INSTALLER_NO_CLEANUP';
+ const string BUILD_NOW = 'VORTEX_INSTALLER_BUILD_NOW';
+
/**
* Store of configuration values.
*
diff --git a/.vortex/installer/src/Utils/Tui.php b/.vortex/installer/src/Utils/Tui.php
index f9ff94dee..e978938a6 100644
--- a/.vortex/installer/src/Utils/Tui.php
+++ b/.vortex/installer/src/Utils/Tui.php
@@ -7,7 +7,9 @@
use Laravel\Prompts\Prompt;
use Laravel\Prompts\Terminal;
use Symfony\Component\Console\Output\OutputInterface;
+use function Laravel\Prompts\confirm;
use function Laravel\Prompts\error;
+use function Laravel\Prompts\info;
use function Laravel\Prompts\intro;
use function Laravel\Prompts\note;
use function Laravel\Prompts\table;
@@ -42,6 +44,11 @@ public static function output(): OutputInterface {
return static::$output;
}
+ public static function setOutput(OutputInterface $output): void {
+ static::$output = $output;
+ Prompt::setOutput($output);
+ }
+
public static function info(string $message): void {
intro($message);
}
@@ -50,10 +57,26 @@ public static function note(string $message): void {
note($message);
}
+ public static function success(string $message): void {
+ info($message);
+ }
+
public static function error(string $message): void {
error('✕ ' . $message);
}
+ public static function confirm(string $label, bool $default = TRUE, ?string $hint = NULL): bool {
+ if (!static::$isInteractive) {
+ return $default;
+ }
+
+ return confirm(
+ label: $label,
+ default: $default,
+ hint: $hint ?? '',
+ );
+ }
+
public static function line(string $message, int $padding = 1): void {
static::$output->writeln(str_repeat(' ', max(0, $padding)) . $message);
}
@@ -87,6 +110,8 @@ public static function underscore(string $text): string {
}
public static function dim(string $text): string {
+ // Replace reset codes with reset+dim to maintain dim through color resets.
+ $text = str_replace("\033[0m", "\033[0m\033[2m", $text);
return static::escapeMultiline($text, 2, 22);
}
diff --git a/.vortex/installer/tests/Functional/Command/BuildCommandTest.php b/.vortex/installer/tests/Functional/Command/BuildCommandTest.php
new file mode 100644
index 000000000..5da5ef224
--- /dev/null
+++ b/.vortex/installer/tests/Functional/Command/BuildCommandTest.php
@@ -0,0 +1,399 @@
+createMock(ProcessRunner::class);
+
+ // Set up common default behaviors.
+ $current_command = '';
+ $build_process_runner->method('run')
+ ->willReturnCallback(function (string $command) use ($build_process_runner, &$current_command): MockObject {
+ $current_command = $command;
+ return $build_process_runner;
+ });
+
+ // Mock getOutput() to handle both string and array returns.
+ $build_process_runner->method('getOutput')->willReturnCallback(fn(bool $as_array = FALSE): array|string => $as_array ? ['Mock build output line 1', 'Mock build output line 2'] : 'Mock build output');
+ $build_process_runner->method('getCommand')->willReturn('ahoy build');
+
+ // Set up getExitCode using the provided callback.
+ $build_process_runner->method('getExitCode')
+ ->willReturnCallback(function () use ($exit_code_callback, &$current_command) {
+ return $exit_code_callback($current_command);
+ });
+
+ // Mock logger to prevent errors in showSuccessSummary/showFailureSummary.
+ $mock_logger = $this->createMock(FileLoggerInterface::class);
+ $mock_logger->method('getPath')->willReturn('/tmp/mock.log');
+ $build_process_runner->method('getLogger')->willReturn($mock_logger);
+
+ // Mock setCwd to return runner for method chaining.
+ $build_process_runner->method('setCwd')->willReturn($build_process_runner);
+
+ // Always register CheckRequirementsCommand with mocked runner and finder.
+ // Mock ExecutableFinder.
+ $requirements_finder = $this->createMock(ExecutableFinder::class);
+ $final_finder_callback = $requirements_finder_callback ?? fn(string $name): string => '/usr/bin/' . $name;
+ $requirements_finder->method('find')
+ ->willReturnCallback(fn(string $name) => $final_finder_callback($name));
+
+ // Mock ExecutableFinder for BuildCommand's ProcessRunner.
+ $build_process_runner->method('getExecutableFinder')->willReturn($requirements_finder);
+
+ // Create command and inject mock runner using setProcessRunner().
+ $build_command = new BuildCommand();
+ $build_command->setProcessRunner($build_process_runner);
+
+ // Initialize application with our command.
+ static::applicationInitFromCommand($build_command);
+
+ // Mock ProcessRunner - use provided callback or default to
+ // success (exit code 0).
+ $requirements_runner = $this->createMock(ProcessRunner::class);
+
+ $current_requirements_command = '';
+ $requirements_runner->method('run')
+ ->willReturnCallback(function (string $command) use ($requirements_runner, &$current_requirements_command): MockObject {
+ $current_requirements_command = $command;
+ return $requirements_runner;
+ });
+
+ $requirements_runner->method('getOutput')->willReturn('version 1.0.0');
+
+ // Use provided callback or default to always returning 0 (success).
+ $final_requirements_callback = $requirements_exit_callback ?? fn(string $current_command): int => 0;
+ $requirements_runner->method('getExitCode')
+ ->willReturnCallback(function () use ($final_requirements_callback, &$current_requirements_command) {
+ return $final_requirements_callback($current_requirements_command);
+ });
+
+ // Mock ExecutableFinder for CheckRequirementsCommand's ProcessRunner.
+ $requirements_runner->method('getExecutableFinder')->willReturn($requirements_finder);
+
+ $check_command = new CheckRequirementsCommand();
+ $check_command->setExecutableFinder($requirements_finder);
+ $check_command->setProcessRunner($requirements_runner);
+ $this->applicationGet()->add($check_command);
+
+ // Run build with provided inputs.
+ $this->applicationRun($command_inputs, [], $expect_failure);
+
+ // Assert output.
+ if (!empty($output_assertions)) {
+ $this->assertApplicationAnyOutputContainsOrNot($output_assertions);
+ }
+ }
+
+ /**
+ * Data provider for testBuildWithMockedRunner.
+ *
+ * @return array,
+ * expect_failure: bool,
+ * output_assertions: array,
+ * requirements_exit_callback?: ?\Closure,
+ * requirements_finder_callback?: ?\Closure
+ * }>
+ */
+ public static function dataProviderBuildCommand(): array {
+ return [
+ // -----------------------------------------------------------------------
+ // Requirements check scenarios.
+ // -----------------------------------------------------------------------
+ 'Build runs requirements check by default, success' => [
+ 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS,
+ 'command_inputs' => [],
+ 'expect_failure' => FALSE,
+ 'output_assertions' => array_merge(
+ TuiOutput::present([
+ TuiOutput::BUILD_CHECKING_REQUIREMENTS,
+ TuiOutput::BUILD_BUILDING_SITE,
+ TuiOutput::BUILD_BUILD_COMPLETED,
+ ]),
+ TuiOutput::absent([
+ TuiOutput::BUILD_EXPORT_DATABASE,
+ ]),
+ ),
+ 'requirements_exit_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS,
+ ],
+
+ 'Requirements check fails - one missing, Docker' => [
+ 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS,
+ 'command_inputs' => [],
+ 'expect_failure' => TRUE,
+ 'output_assertions' => array_merge(
+ TuiOutput::present([
+ TuiOutput::BUILD_CHECKING_REQUIREMENTS,
+ TuiOutput::CHECK_REQUIREMENTS_MISSING,
+ ]),
+ TuiOutput::absent([
+ TuiOutput::BUILD_BUILDING_SITE,
+ TuiOutput::BUILD_BUILD_COMPLETED,
+ ]),
+ ),
+ 'requirements_exit_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS,
+ 'requirements_finder_callback' => fn(string $name): ?string => $name === 'docker' ? NULL : '/usr/bin/' . $name,
+ ],
+
+ 'Requirements check fails - all missing' => [
+ 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS,
+ 'command_inputs' => [],
+ 'expect_failure' => TRUE,
+ 'output_assertions' => array_merge(
+ TuiOutput::present([
+ TuiOutput::BUILD_CHECKING_REQUIREMENTS,
+ TuiOutput::CHECK_REQUIREMENTS_MISSING,
+ ]),
+ TuiOutput::absent([
+ TuiOutput::BUILD_BUILDING_SITE,
+ TuiOutput::BUILD_BUILD_COMPLETED,
+ ]),
+ ),
+ 'requirements_exit_callback' => fn(string $current_command): int => RunnerInterface::EXIT_COMMAND_NOT_FOUND,
+ 'requirements_finder_callback' => fn(string $name): ?string => NULL,
+ ],
+
+ // -----------------------------------------------------------------------
+ // Basic build scenarios.
+ // -----------------------------------------------------------------------
+ 'Build with skip requirements check, success' => [
+ 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS,
+ 'command_inputs' => ['--skip-requirements-check' => TRUE],
+ 'expect_failure' => FALSE,
+ 'output_assertions' => array_merge(
+ TuiOutput::present([
+ TuiOutput::BUILD_BUILDING_SITE,
+ TuiOutput::BUILD_BUILD_COMPLETED,
+ ]),
+ TuiOutput::absent([
+ TuiOutput::BUILD_CHECKING_REQUIREMENTS,
+ TuiOutput::BUILD_EXPORT_DATABASE,
+ ]),
+ ),
+
+ ],
+
+ // -----------------------------------------------------------------------
+ // Profile flag scenarios.
+ // -----------------------------------------------------------------------
+ 'Build with profile flag and skip requirements, success' => [
+ 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS,
+ 'command_inputs' => [
+ '--profile' => TRUE,
+ '--skip-requirements-check' => TRUE,
+ ],
+ 'expect_failure' => FALSE,
+ 'output_assertions' => array_merge(
+ TuiOutput::present([
+ TuiOutput::BUILD_BUILDING_SITE,
+ TuiOutput::BUILD_BUILD_COMPLETED,
+ TuiOutput::BUILD_EXPORT_DATABASE,
+ ]),
+ TuiOutput::absent([
+ TuiOutput::BUILD_CHECKING_REQUIREMENTS,
+ ]),
+ ),
+
+ ],
+
+ 'Build with profile flag and requirements check, success' => [
+ 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS,
+ 'command_inputs' => ['--profile' => TRUE],
+ 'expect_failure' => FALSE,
+ 'output_assertions' => TuiOutput::present([
+ TuiOutput::BUILD_CHECKING_REQUIREMENTS,
+ TuiOutput::BUILD_BUILDING_SITE,
+ TuiOutput::BUILD_BUILD_COMPLETED,
+ TuiOutput::BUILD_EXPORT_DATABASE,
+ ]),
+ 'requirements_exit_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS,
+ ],
+
+ 'Build with profile shows export database step' => [
+ 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS,
+ 'command_inputs' => [
+ '--profile' => TRUE,
+ '--skip-requirements-check' => TRUE,
+ ],
+ 'expect_failure' => FALSE,
+ 'output_assertions' => [
+ ...TuiOutput::present([
+ TuiOutput::BUILD_BUILDING_SITE,
+ TuiOutput::BUILD_BUILD_COMPLETED,
+ ]),
+ '* ' . TuiOutput::BUILD_EXPORT_DATABASE . ' ahoy export-db',
+ ],
+
+ ],
+
+ // -----------------------------------------------------------------------
+ // Build failure scenarios.
+ // -----------------------------------------------------------------------
+ 'Build failure, ahoy build fails, exit code 1' => [
+ 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_FAILURE,
+ 'command_inputs' => ['--skip-requirements-check' => TRUE],
+ 'expect_failure' => TRUE,
+ 'output_assertions' => array_merge(
+ TuiOutput::present([
+ TuiOutput::BUILD_BUILDING_SITE,
+ TuiOutput::BUILD_BUILD_FAILED,
+ ]),
+ ['* ' . TuiOutput::INSTALL_EXIT_CODE . ' 1'],
+ TuiOutput::absent([
+ TuiOutput::BUILD_BUILD_COMPLETED,
+ ]),
+ ),
+
+ ],
+
+ 'Build failure with profile, ahoy build fails, exit code 1' => [
+ 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_FAILURE,
+ 'command_inputs' => [
+ '--profile' => TRUE,
+ '--skip-requirements-check' => TRUE,
+ ],
+ 'expect_failure' => TRUE,
+ 'output_assertions' => array_merge(
+ TuiOutput::present([
+ TuiOutput::BUILD_BUILDING_SITE,
+ TuiOutput::BUILD_BUILD_FAILED,
+ ]),
+ ['* ' . TuiOutput::INSTALL_EXIT_CODE . ' 1'],
+ TuiOutput::absent([
+ TuiOutput::BUILD_BUILD_COMPLETED,
+ TuiOutput::BUILD_EXPORT_DATABASE,
+ ]),
+ ),
+
+ ],
+
+ 'Build failure, ahoy build fails, exit code 2' => [
+ 'exit_code_callback' => fn(string $current_command): int => 2,
+ 'command_inputs' => ['--skip-requirements-check' => TRUE],
+ 'expect_failure' => TRUE,
+ 'output_assertions' => array_merge(
+ TuiOutput::present([
+ TuiOutput::BUILD_BUILDING_SITE,
+ TuiOutput::BUILD_BUILD_FAILED,
+ ]),
+ ['* ' . TuiOutput::INSTALL_EXIT_CODE . ' 2'],
+ TuiOutput::absent([
+ TuiOutput::BUILD_BUILD_COMPLETED,
+ ]),
+ ),
+
+ ],
+
+ 'Build failure, ahoy build fails, exit code 127' => [
+ 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_COMMAND_NOT_FOUND,
+ 'command_inputs' => ['--skip-requirements-check' => TRUE],
+ 'expect_failure' => TRUE,
+ 'output_assertions' => array_merge(
+ TuiOutput::present([
+ TuiOutput::BUILD_BUILDING_SITE,
+ TuiOutput::BUILD_BUILD_FAILED,
+ ]),
+ ['* ' . TuiOutput::INSTALL_EXIT_CODE . ' 127'],
+ TuiOutput::absent([
+ TuiOutput::BUILD_BUILD_COMPLETED,
+ ]),
+ ),
+
+ ],
+
+ 'Build failure shows log file path' => [
+ 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_FAILURE,
+ 'command_inputs' => ['--skip-requirements-check' => TRUE],
+ 'expect_failure' => TRUE,
+ 'output_assertions' => array_merge(
+ TuiOutput::present([
+ TuiOutput::BUILD_BUILDING_SITE,
+ TuiOutput::BUILD_BUILD_FAILED,
+ ]),
+ ['* ' . TuiOutput::INSTALL_LOG_FILE . ' /tmp/mock.log'],
+ ),
+
+ ],
+
+ // -----------------------------------------------------------------------
+ // Success output verification scenarios.
+ // -----------------------------------------------------------------------
+ 'Build success shows log file path' => [
+ 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS,
+ 'command_inputs' => ['--skip-requirements-check' => TRUE],
+ 'expect_failure' => FALSE,
+ 'output_assertions' => array_merge(
+ TuiOutput::present([
+ TuiOutput::BUILD_BUILDING_SITE,
+ TuiOutput::BUILD_BUILD_COMPLETED,
+ ]),
+ ['* ' . TuiOutput::INSTALL_LOG_FILE . ' /tmp/mock.log'],
+ ),
+
+ ],
+
+ 'Build success shows site URL' => [
+ 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS,
+ 'command_inputs' => ['--skip-requirements-check' => TRUE],
+ 'expect_failure' => FALSE,
+ 'output_assertions' => array_merge(
+ TuiOutput::present([
+ TuiOutput::BUILD_BUILDING_SITE,
+ TuiOutput::BUILD_BUILD_COMPLETED,
+ TuiOutput::INSTALL_LOGIN,
+ ]),
+ ['* ' . TuiOutput::BUILD_SITE_URL . ' http://'],
+ ),
+
+ ],
+
+ 'Build success shows next steps' => [
+ 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS,
+ 'command_inputs' => ['--skip-requirements-check' => TRUE],
+ 'expect_failure' => FALSE,
+ 'output_assertions' => TuiOutput::present([
+ TuiOutput::BUILD_BUILDING_SITE,
+ TuiOutput::BUILD_BUILD_COMPLETED,
+ TuiOutput::INSTALL_NEXT_STEPS,
+ TuiOutput::BUILD_REVIEW_DOCS,
+ ]),
+
+ ],
+ ];
+ }
+
+}
diff --git a/.vortex/installer/tests/Functional/Command/CheckRequirementsCommandTest.php b/.vortex/installer/tests/Functional/Command/CheckRequirementsCommandTest.php
new file mode 100644
index 000000000..ac8ed7297
--- /dev/null
+++ b/.vortex/installer/tests/Functional/Command/CheckRequirementsCommandTest.php
@@ -0,0 +1,388 @@
+createMock(ExecutableFinder::class);
+ $mock_finder->method('find')
+ ->willReturnCallback(fn(string $name) => $executable_finder_callback($name));
+
+ // Create a mock ProcessRunner.
+ $mock_runner = $this->createMock(ProcessRunner::class);
+
+ // Set up common default behaviors.
+ $current_command = '';
+ $mock_runner->method('run')
+ ->willReturnCallback(function (string $command) use ($mock_runner, &$current_command): MockObject {
+ $current_command = $command;
+ return $mock_runner;
+ });
+
+ $mock_runner->method('getOutput')->willReturn('version 1.0.0');
+
+ // Set up getExitCode using the provided callback.
+ $mock_runner->method('getExitCode')
+ ->willReturnCallback(function () use ($exit_code_callback, &$current_command) {
+ return $exit_code_callback($current_command);
+ });
+
+ // Create command and inject mocks using setters.
+ $command = new CheckRequirementsCommand();
+ $command->setExecutableFinder($mock_finder);
+ $command->setProcessRunner($mock_runner);
+
+ // Initialize application with our command.
+ static::applicationInitFromCommand($command);
+
+ // Run check with provided inputs.
+ $this->applicationRun($command_inputs, [], $expect_failure);
+
+ if (!empty($output_assertions)) {
+ $this->assertApplicationAnyOutputContainsOrNot($output_assertions);
+ }
+ }
+
+ /**
+ * Data provider for testCheckRequirementsCommand.
+ *
+ * @return array,
+ * expect_failure: bool,
+ * output_assertions: array
+ * }>
+ */
+ public static function dataProviderCheckRequirementsCommand(): array {
+ return [
+ 'Check all requirements' => [
+ 'executable_finder_callback' => fn(string $name): string => '/usr/bin/' . $name,
+ 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS,
+ 'command_inputs' => [],
+ 'expect_failure' => FALSE,
+ 'output_assertions' => array_merge(
+ TuiOutput::present([
+ TuiOutput::CHECK_REQUIREMENTS_ALL_MET,
+ TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL,
+ ]),
+ [
+ '* Docker: version 1.0.0',
+ '* Docker Compose: version 1.0.0',
+ '* Ahoy: version 1.0.0',
+ '* Pygmy: version 1.0.0',
+ ],
+ ),
+ ],
+
+ 'All requirements missing' => [
+ 'executable_finder_callback' => fn(string $name): ?string => NULL,
+ 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_COMMAND_NOT_FOUND,
+ 'command_inputs' => [],
+ 'expect_failure' => TRUE,
+ 'output_assertions' => array_merge(
+ TuiOutput::present([
+ TuiOutput::CHECK_REQUIREMENTS_MISSING,
+ TuiOutput::CHECK_REQUIREMENTS_MISSING_LABEL,
+ ]),
+ [
+ '* Docker:',
+ '* Docker Compose:',
+ '* Ahoy:',
+ '* Pygmy:',
+ ],
+ TuiOutput::absent([
+ TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL,
+ TuiOutput::CHECK_REQUIREMENTS_ALL_MET,
+ ]),
+ ),
+ ],
+
+ 'Check only Docker' => [
+ 'executable_finder_callback' => fn(string $name): string => '/usr/bin/' . $name,
+ 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS,
+ 'command_inputs' => ['--only' => 'docker'],
+ 'expect_failure' => FALSE,
+ 'output_assertions' => array_merge(
+ TuiOutput::present([
+ TuiOutput::CHECK_REQUIREMENTS_ALL_MET,
+ TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL,
+ ]),
+ ['* Docker: version 1.0.0'],
+ ['! Ahoy:', '! Pygmy:'],
+ ),
+ ],
+
+ 'Check only Docker and Ahoy' => [
+ 'executable_finder_callback' => fn(string $name): string => '/usr/bin/' . $name,
+ 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS,
+ 'command_inputs' => ['--only' => 'docker,ahoy'],
+ 'expect_failure' => FALSE,
+ 'output_assertions' => array_merge(
+ TuiOutput::present([
+ TuiOutput::CHECK_REQUIREMENTS_ALL_MET,
+ TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL,
+ ]),
+ [
+ '* Docker: version 1.0.0',
+ '* Ahoy: version 1.0.0',
+ ],
+ ['! Pygmy:'],
+ ),
+ ],
+
+ 'Check with no-summary option' => [
+ 'executable_finder_callback' => fn(string $name): string => '/usr/bin/' . $name,
+ 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS,
+ 'command_inputs' => ['--no-summary' => TRUE],
+ 'expect_failure' => FALSE,
+ 'output_assertions' => array_merge(
+ TuiOutput::present([
+ TuiOutput::CHECK_REQUIREMENTS_ALL_MET,
+ ]),
+ TuiOutput::absent([
+ TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL,
+ TuiOutput::CHECK_REQUIREMENTS_MISSING_LABEL,
+ ]),
+ ),
+ ],
+
+ 'Docker missing' => [
+ 'executable_finder_callback' => fn(string $name): ?string => $name === 'docker' ? NULL : '/usr/bin/' . $name,
+ 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS,
+ 'command_inputs' => ['--only' => 'docker'],
+ 'expect_failure' => TRUE,
+ 'output_assertions' => array_merge(
+ TuiOutput::present([
+ TuiOutput::CHECK_REQUIREMENTS_MISSING,
+ TuiOutput::CHECK_REQUIREMENTS_MISSING_LABEL,
+ ]),
+ ['* Docker:'],
+ TuiOutput::absent([
+ TuiOutput::CHECK_REQUIREMENTS_DOCKER_AVAILABLE,
+ TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL,
+ ]),
+ ),
+ ],
+
+ 'Ahoy missing' => [
+ 'executable_finder_callback' => fn(string $name): ?string => $name === 'ahoy' ? NULL : '/usr/bin/' . $name,
+ 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS,
+ 'command_inputs' => ['--only' => 'ahoy'],
+ 'expect_failure' => TRUE,
+ 'output_assertions' => array_merge(
+ TuiOutput::present([
+ TuiOutput::CHECK_REQUIREMENTS_MISSING,
+ TuiOutput::CHECK_REQUIREMENTS_MISSING_LABEL,
+ ]),
+ ['* Ahoy:'],
+ TuiOutput::absent([
+ TuiOutput::CHECK_REQUIREMENTS_AHOY_AVAILABLE,
+ TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL,
+ ]),
+ ),
+ ],
+
+ 'Pygmy command not found' => [
+ 'executable_finder_callback' => fn(string $name): ?string => $name === 'pygmy' ? NULL : '/usr/bin/' . $name,
+ 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS,
+ 'command_inputs' => ['--only' => 'pygmy'],
+ 'expect_failure' => TRUE,
+ 'output_assertions' => array_merge(
+ TuiOutput::present([
+ TuiOutput::CHECK_REQUIREMENTS_MISSING,
+ TuiOutput::CHECK_REQUIREMENTS_MISSING_LABEL,
+ ]),
+ ['* Pygmy:'],
+ TuiOutput::absent([
+ TuiOutput::CHECK_REQUIREMENTS_PYGMY_RUNNING,
+ TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL,
+ ]),
+ ),
+ ],
+
+ 'Pygmy status command succeeds' => [
+ 'executable_finder_callback' => fn(string $name): string => '/usr/bin/' . $name,
+ 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS,
+ 'command_inputs' => ['--only' => 'pygmy'],
+ 'expect_failure' => FALSE,
+ 'output_assertions' => array_merge(
+ TuiOutput::present([
+ TuiOutput::CHECK_REQUIREMENTS_ALL_MET,
+ TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL,
+ ]),
+ ['* Pygmy: version 1.0.0'],
+ TuiOutput::absent([
+ TuiOutput::CHECK_REQUIREMENTS_MISSING_LABEL,
+ ]),
+ ),
+ ],
+
+ 'Pygmy status fails but amazeeio containers found' => [
+ 'executable_finder_callback' => fn(string $name): string => '/usr/bin/' . $name,
+ 'exit_code_callback' => function (string $current_command): int {
+ // Pygmy status fails.
+ if (str_contains($current_command, 'pygmy status')) {
+ return RunnerInterface::EXIT_FAILURE;
+ }
+ return RunnerInterface::EXIT_SUCCESS;
+ },
+ 'command_inputs' => ['--only' => 'pygmy'],
+ 'expect_failure' => FALSE,
+ 'output_assertions' => array_merge(
+ TuiOutput::present([
+ TuiOutput::CHECK_REQUIREMENTS_ALL_MET,
+ TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL,
+ ]),
+ ['* Pygmy: version 1.0.0'],
+ TuiOutput::absent([
+ TuiOutput::CHECK_REQUIREMENTS_MISSING_LABEL,
+ ]),
+ ),
+ ],
+
+ 'Pygmy status fails and no amazeeio containers' => [
+ 'executable_finder_callback' => fn(string $name): string => '/usr/bin/' . $name,
+ 'exit_code_callback' => function (string $current_command): int {
+ // Pygmy status fails.
+ if (str_contains($current_command, 'pygmy status')) {
+ return RunnerInterface::EXIT_FAILURE;
+ }
+ // No amazeeio containers.
+ if (str_contains($current_command, 'docker ps') && str_contains($current_command, 'amazeeio')) {
+ return RunnerInterface::EXIT_FAILURE;
+ }
+ return RunnerInterface::EXIT_SUCCESS;
+ },
+ 'command_inputs' => ['--only' => 'pygmy'],
+ 'expect_failure' => TRUE,
+ 'output_assertions' => array_merge(
+ TuiOutput::present([
+ TuiOutput::CHECK_REQUIREMENTS_MISSING,
+ TuiOutput::CHECK_REQUIREMENTS_MISSING_LABEL,
+ ]),
+ ['* Pygmy:'],
+ TuiOutput::absent([
+ TuiOutput::CHECK_REQUIREMENTS_PYGMY_RUNNING,
+ TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL,
+ ]),
+ ),
+ ],
+
+ 'Docker Compose via modern syntax' => [
+ 'executable_finder_callback' => fn(string $name): string => '/usr/bin/' . $name,
+ 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS,
+ 'command_inputs' => ['--only' => 'docker-compose'],
+ 'expect_failure' => FALSE,
+ 'output_assertions' => array_merge(
+ TuiOutput::present([
+ TuiOutput::CHECK_REQUIREMENTS_ALL_MET,
+ TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL,
+ ]),
+ ['* Docker Compose: version 1.0.0'],
+ TuiOutput::absent([
+ TuiOutput::CHECK_REQUIREMENTS_MISSING_LABEL,
+ ]),
+ ),
+ ],
+
+ 'Docker Compose via legacy command' => [
+ 'executable_finder_callback' => fn(string $name): string => '/usr/bin/' . $name,
+ 'exit_code_callback' => function (string $current_command): int {
+ // Modern syntax fails.
+ if (str_contains($current_command, 'docker compose version')) {
+ return RunnerInterface::EXIT_COMMAND_NOT_FOUND;
+ }
+ return RunnerInterface::EXIT_SUCCESS;
+ },
+ 'command_inputs' => ['--only' => 'docker-compose'],
+ 'expect_failure' => FALSE,
+ 'output_assertions' => array_merge(
+ TuiOutput::present([
+ TuiOutput::CHECK_REQUIREMENTS_ALL_MET,
+ TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL,
+ ]),
+ ['* Docker Compose: version 1.0.0'],
+ TuiOutput::absent([
+ TuiOutput::CHECK_REQUIREMENTS_MISSING_LABEL,
+ ]),
+ ),
+ ],
+
+ 'Docker Compose missing completely' => [
+ 'executable_finder_callback' => fn(string $name): ?string => $name === 'docker-compose' ? NULL : '/usr/bin/' . $name,
+ 'exit_code_callback' => function (string $current_command): int {
+ // Modern docker compose command fails.
+ if (str_contains($current_command, 'docker compose version')) {
+ return RunnerInterface::EXIT_COMMAND_NOT_FOUND;
+ }
+ return RunnerInterface::EXIT_SUCCESS;
+ },
+ 'command_inputs' => ['--only' => 'docker-compose'],
+ 'expect_failure' => TRUE,
+ 'output_assertions' => array_merge(
+ TuiOutput::present([
+ TuiOutput::CHECK_REQUIREMENTS_MISSING,
+ TuiOutput::CHECK_REQUIREMENTS_MISSING_LABEL,
+ ]),
+ ['* Docker Compose:'],
+ TuiOutput::absent([
+ TuiOutput::CHECK_REQUIREMENTS_DOCKER_COMPOSE_AVAILABLE,
+ TuiOutput::CHECK_REQUIREMENTS_PRESENT_LABEL,
+ ]),
+ ),
+ ],
+
+ 'Invalid requirement name' => [
+ 'executable_finder_callback' => fn(string $name): string => '/usr/bin/' . $name,
+ 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS,
+ 'command_inputs' => ['--only' => 'invalid'],
+ 'expect_failure' => TRUE,
+ 'output_assertions' => [
+ '* ' . TuiOutput::CHECK_REQUIREMENTS_UNKNOWN . ' invalid',
+ '* Available: docker, docker-compose, ahoy',
+ ],
+ ],
+
+ 'Mixed valid and invalid requirements' => [
+ 'executable_finder_callback' => fn(string $name): string => '/usr/bin/' . $name,
+ 'exit_code_callback' => fn(string $current_command): int => RunnerInterface::EXIT_SUCCESS,
+ 'command_inputs' => ['--only' => 'docker,invalid'],
+ 'expect_failure' => TRUE,
+ 'output_assertions' => [
+ '* ' . TuiOutput::CHECK_REQUIREMENTS_UNKNOWN . ' invalid',
+ '* Available: docker, docker-compose, ahoy',
+ ],
+ ],
+ ];
+ }
+
+}
diff --git a/.vortex/installer/tests/Functional/Command/InstallCommandTest.php b/.vortex/installer/tests/Functional/Command/InstallCommandTest.php
new file mode 100644
index 000000000..e8d7fc447
--- /dev/null
+++ b/.vortex/installer/tests/Functional/Command/InstallCommandTest.php
@@ -0,0 +1,537 @@
+createMock(ExecutableFinder::class);
+ $executable_finder->method('find')
+ ->willReturnCallback(fn(string $name) => $install_executable_finder_find_callback($name));
+
+ // 2. Mock ProcessRunner for BuildCommand (runs 'ahoy build').
+ $build_runner = $this->createMock(ProcessRunner::class);
+ $build_runner_command = '';
+ $build_runner->method('run')
+ ->willReturnCallback(function (string $command) use ($build_runner, &$build_runner_command): MockObject {
+ $build_runner_command = $command;
+ return $build_runner;
+ });
+ $build_runner->method('getExitCode')
+ ->willReturnCallback(function () use ($build_runner_exit_callback, &$build_runner_command) {
+ return $build_runner_exit_callback($build_runner_command);
+ });
+ // Mock other BuildCommand runner methods.
+ $build_runner->method('getOutput')->willReturnCallback(fn(bool $as_array = FALSE): array | string => $as_array ? ['Mock build output line 1', 'Mock build output line 2'] : 'Mock build output');
+ $build_runner->method('getCommand')->willReturn('ahoy build');
+ $mock_logger = $this->createMock(FileLoggerInterface::class);
+ $mock_logger->method('getPath')->willReturn('/tmp/mock.log');
+ $build_runner->method('getLogger')->willReturn($mock_logger);
+ $build_runner->method('setCwd')->willReturn($build_runner);
+ // Mock ExecutableFinder for BuildCommand's ProcessRunner.
+ $build_runner->method('getExecutableFinder')->willReturn($executable_finder);
+
+ // 3. Mock ProcessRunner for CheckRequirementsCommand.
+ $check_requirements_runner = $this->createMock(ProcessRunner::class);
+ $check_requirements_runner_command = '';
+ $check_requirements_runner->method('run')
+ ->willReturnCallback(function (string $command) use ($check_requirements_runner, &$check_requirements_runner_command): MockObject {
+ $check_requirements_runner_command = $command;
+ return $check_requirements_runner;
+ });
+ $check_requirements_runner->method('getOutput')->willReturn('version 1.0.0');
+ $check_requirements_runner->method('getExitCode')
+ ->willReturnCallback(function () use ($check_requirements_runner_exit_callback, &$check_requirements_runner_command) {
+ return $check_requirements_runner_exit_callback($check_requirements_runner_command);
+ });
+ // Mock ExecutableFinder for CheckRequirementsCommand's ProcessRunner.
+ $check_requirements_runner->method('getExecutableFinder')->willReturn($executable_finder);
+
+ // Create and configure InstallCommand.
+ $install_command = new InstallCommand();
+ $install_command->setExecutableFinder($executable_finder);
+
+ if ($download_should_fail) {
+ $mock_downloader = $this->createMock(Downloader::class);
+ $mock_downloader->method('download')->willThrowException(new \RuntimeException('Failed to download Vortex.'));
+ $install_command->setDownloader($mock_downloader);
+ }
+ else {
+ // Download from root as a real repository. This is long, but there is
+ // no other way to test the rest of the installation process without
+ // having all files in place.
+ $command_inputs['--' . InstallCommand::OPTION_URI] = File::dir(static::$root);
+ }
+
+ // Initialize application and register mocked commands.
+ static::applicationInitFromCommand($install_command);
+
+ $check_command = new CheckRequirementsCommand();
+ $check_command->setExecutableFinder($executable_finder);
+ $check_command->setProcessRunner($check_requirements_runner);
+ $this->applicationGet()->add($check_command);
+
+ $build_command = new BuildCommand();
+ $build_command->setProcessRunner($build_runner);
+ $this->applicationGet()->add($build_command);
+
+ $command_inputs['--' . InstallCommand::OPTION_DESTINATION] = self::$sut;
+
+ $this->applicationRun($command_inputs, [], $expect_failure);
+
+ if (!empty($output_assertions)) {
+ $this->assertApplicationAnyOutputContainsOrNot($output_assertions);
+ }
+ }
+
+ /**
+ * Data provider for testInstallCommand.
+ *
+ * @return array,
+ * install_executable_finder_find_callback: \Closure,
+ * build_runner_exit_callback: \Closure,
+ * check_requirements_runner_exit_callback: \Closure,
+ * expect_failure: bool,
+ * output_assertions: array,
+ * download_should_fail?: bool
+ * }>
+ */
+ public static function dataProviderInstallCommand(): array {
+ return [
+ 'Install without build flag, skips build' => [
+ 'command_inputs' => self::tuiOptions([
+ InstallCommand::OPTION_NO_INTERACTION => TRUE,
+ ]),
+ 'install_executable_finder_find_callback' => fn(string $command): string => '/usr/bin/' . $command,
+ 'build_runner_exit_callback' => fn(string $command): int => RunnerInterface::EXIT_SUCCESS,
+ 'check_requirements_runner_exit_callback' => fn(string $command): int => RunnerInterface::EXIT_SUCCESS,
+ 'expect_failure' => FALSE,
+ 'output_assertions' => [
+ ...TuiOutput::present([
+ TuiOutput::INSTALL_STARTING,
+ TuiOutput::INSTALL_DOWNLOADING,
+ TuiOutput::INSTALL_CUSTOMIZING,
+ TuiOutput::INSTALL_PREPARING_DESTINATION,
+ TuiOutput::INSTALL_COPYING_FILES,
+ TuiOutput::INSTALL_PREPARING_DEMO,
+ TuiOutput::FOOTER_FINISHED_INSTALLING,
+ TuiOutput::FOOTER_GIT_ADD,
+ TuiOutput::FOOTER_GIT_COMMIT,
+ TuiOutput::FOOTER_READY_TO_BUILD,
+ TuiOutput::FOOTER_BUILD_THE_SITE,
+ TuiOutput::FOOTER_AHOY_BUILD,
+ TuiOutput::POSTBUILD_SETUP_GHA,
+ ]),
+ ...TuiOutput::absent([
+ TuiOutput::INSTALL_BUILDING,
+ TuiOutput::FOOTER_SITE_READY,
+ ]),
+ ],
+ ],
+
+ 'Install with config JSON string succeeds' => [
+ 'command_inputs' => self::tuiOptions([
+ InstallCommand::OPTION_NO_INTERACTION => TRUE,
+ InstallCommand::OPTION_CONFIG => '{"VORTEX_PROJECT_NAME":"test_project"}',
+ ]),
+ 'install_executable_finder_find_callback' => fn(string $command): string => '/usr/bin/' . $command,
+ 'build_runner_exit_callback' => fn(string $command): int => RunnerInterface::EXIT_SUCCESS,
+ 'check_requirements_runner_exit_callback' => fn(string $command): int => RunnerInterface::EXIT_SUCCESS,
+ 'expect_failure' => FALSE,
+ 'output_assertions' => [
+ ...TuiOutput::present([
+ TuiOutput::INSTALL_STARTING,
+ TuiOutput::INSTALL_DOWNLOADING,
+ TuiOutput::INSTALL_CUSTOMIZING,
+ ]),
+ ],
+ ],
+
+ 'Install with no-cleanup flag succeeds' => [
+ 'command_inputs' => self::tuiOptions([
+ InstallCommand::OPTION_NO_INTERACTION => TRUE,
+ InstallCommand::OPTION_NO_CLEANUP => TRUE,
+ ]),
+ 'install_executable_finder_find_callback' => fn(string $command): string => '/usr/bin/' . $command,
+ 'build_runner_exit_callback' => fn(string $command): int => RunnerInterface::EXIT_SUCCESS,
+ 'check_requirements_runner_exit_callback' => fn(string $command): int => RunnerInterface::EXIT_SUCCESS,
+ 'expect_failure' => FALSE,
+ 'output_assertions' => [
+ ...TuiOutput::present([
+ TuiOutput::INSTALL_STARTING,
+ TuiOutput::INSTALL_DOWNLOADING,
+ TuiOutput::INSTALL_CUSTOMIZING,
+ ]),
+ ],
+ ],
+
+ // -----------------------------------------------------------------------
+ // Install command fails requirements check.
+ // -----------------------------------------------------------------------
+ 'Requirements of install command check fails, missing git' => [
+ 'command_inputs' => self::tuiOptions([
+ InstallCommand::OPTION_NO_INTERACTION => TRUE,
+ ]),
+ 'install_executable_finder_find_callback' => function (string $command): ?string {
+ // Git command fails.
+ if (str_contains($command, 'git')) {
+ return NULL;
+ }
+ return '/usr/bin/' . $command;
+ },
+ 'build_runner_exit_callback' => fn(string $command): int => RunnerInterface::EXIT_SUCCESS,
+ 'check_requirements_runner_exit_callback' => fn(string $command): int => RunnerInterface::EXIT_SUCCESS,
+ 'expect_failure' => TRUE,
+ 'output_assertions' => [
+ ...TuiOutput::present([
+ TuiOutput::INSTALL_ERROR_MISSING_GIT,
+ ]),
+ ...TuiOutput::absent([
+ TuiOutput::INSTALL_STARTING,
+ ]),
+ ],
+ ],
+
+ 'Requirements of install command check fails, missing curl' => [
+ 'command_inputs' => self::tuiOptions([
+ InstallCommand::OPTION_NO_INTERACTION => TRUE,
+ ]),
+ 'install_executable_finder_find_callback' => function (string $command): ?string {
+ // Curl command fails.
+ if (str_contains($command, 'curl')) {
+ return NULL;
+ }
+ return '/usr/bin/' . $command;
+ },
+
+ 'build_runner_exit_callback' => fn(string $command): int => RunnerInterface::EXIT_SUCCESS,
+ 'check_requirements_runner_exit_callback' => fn(string $command): int => RunnerInterface::EXIT_SUCCESS,
+ 'expect_failure' => TRUE,
+ 'output_assertions' => [
+ ...TuiOutput::present([
+ TuiOutput::INSTALL_ERROR_MISSING_CURL,
+ ]),
+ ...TuiOutput::absent([
+ TuiOutput::INSTALL_STARTING,
+ ]),
+ ],
+ ],
+
+ 'Requirements of install command check fails, missing tar' => [
+ 'command_inputs' => self::tuiOptions([
+ InstallCommand::OPTION_NO_INTERACTION => TRUE,
+ ]),
+ 'install_executable_finder_find_callback' => function (string $command): ?string {
+ // Tar command fails.
+ if (str_contains($command, 'tar')) {
+ return NULL;
+ }
+ return '/usr/bin/' . $command;
+ },
+
+ 'build_runner_exit_callback' => fn(string $command): int => RunnerInterface::EXIT_SUCCESS,
+ 'check_requirements_runner_exit_callback' => fn(string $command): int => RunnerInterface::EXIT_SUCCESS,
+ 'expect_failure' => TRUE,
+ 'output_assertions' => [
+ ...TuiOutput::present([
+ TuiOutput::INSTALL_ERROR_MISSING_TAR,
+ ]),
+ ...TuiOutput::absent([
+ TuiOutput::INSTALL_STARTING,
+ ]),
+ ],
+ ],
+
+ 'Requirements of install command check fails, missing composer' => [
+ 'command_inputs' => self::tuiOptions([
+ InstallCommand::OPTION_NO_INTERACTION => TRUE,
+ ]),
+ 'install_executable_finder_find_callback' => function (string $command): ?string {
+ // Composer command fails.
+ if (str_contains($command, 'composer')) {
+ return NULL;
+ }
+ return '/usr/bin/' . $command;
+ },
+
+ 'build_runner_exit_callback' => fn(string $command): int => RunnerInterface::EXIT_SUCCESS,
+ 'check_requirements_runner_exit_callback' => fn(string $command): int => RunnerInterface::EXIT_SUCCESS,
+ 'expect_failure' => TRUE,
+ 'output_assertions' => [
+ ...TuiOutput::present([
+ TuiOutput::INSTALL_ERROR_MISSING_COMPOSER,
+ ]),
+ ...TuiOutput::absent([
+ TuiOutput::INSTALL_STARTING,
+ ]),
+ ],
+ ],
+
+ 'Requirements of install command check fails, multiple missing tools' => [
+ 'command_inputs' => self::tuiOptions([
+ InstallCommand::OPTION_NO_INTERACTION => TRUE,
+ ]),
+ 'install_executable_finder_find_callback' => function (string $command): ?string {
+ // Both git and curl commands fails.
+ if (str_contains($command, 'git')) {
+ return NULL;
+ }
+
+ if (str_contains($command, 'curl')) {
+ return NULL;
+ }
+
+ return '/usr/bin/' . $command;
+ },
+ 'build_runner_exit_callback' => fn(string $command): int => RunnerInterface::EXIT_SUCCESS,
+ 'check_requirements_runner_exit_callback' => fn(string $command): int => RunnerInterface::EXIT_SUCCESS,
+ 'expect_failure' => TRUE,
+ 'output_assertions' => [
+ ...TuiOutput::present([
+ TuiOutput::INSTALL_ERROR_MISSING_GIT,
+ ]),
+ ...TuiOutput::absent([
+ TuiOutput::INSTALL_STARTING,
+ ]),
+ ],
+ ],
+
+ // -----------------------------------------------------------------------
+ // Download failures.
+ // -----------------------------------------------------------------------
+ 'Download fails' => [
+ 'command_inputs' => self::tuiOptions([
+ InstallCommand::OPTION_NO_INTERACTION => TRUE,
+ ]),
+ 'install_executable_finder_find_callback' => fn(string $command): string => '/usr/bin/' . $command,
+ 'build_runner_exit_callback' => fn(string $command): int => RunnerInterface::EXIT_SUCCESS,
+ 'check_requirements_runner_exit_callback' => fn(string $command): int => RunnerInterface::EXIT_SUCCESS,
+ 'expect_failure' => TRUE,
+ 'output_assertions' => [
+ ...TuiOutput::present([
+ TuiOutput::INSTALL_STARTING,
+ TuiOutput::INSTALL_DOWNLOADING,
+ TuiOutput::INSTALL_ERROR_DOWNLOAD_FAILED,
+ ]),
+ ...TuiOutput::absent([
+ TuiOutput::INSTALL_CUSTOMIZING,
+ TuiOutput::INSTALL_PREPARING_DESTINATION,
+ ]),
+ ],
+ 'download_should_fail' => TRUE,
+ ],
+
+ // -----------------------------------------------------------------------
+ // Sub-commands: build with check-requirements.
+ // -----------------------------------------------------------------------
+ 'Install with build flag succeeds' => [
+ 'command_inputs' => self::tuiOptions([
+ InstallCommand::OPTION_NO_INTERACTION => TRUE,
+ InstallCommand::OPTION_BUILD => TRUE,
+ ]),
+ 'install_executable_finder_find_callback' => fn(string $command): string => '/usr/bin/' . $command,
+ 'build_runner_exit_callback' => TuiOutput::buildRunnerSuccess(),
+ 'check_requirements_runner_exit_callback' => TuiOutput::checkRequirementsSuccess(),
+ 'expect_failure' => FALSE,
+ 'output_assertions' => [
+ ...TuiOutput::present([
+ TuiOutput::INSTALL_STARTING,
+ TuiOutput::INSTALL_DOWNLOADING,
+ TuiOutput::INSTALL_CUSTOMIZING,
+ TuiOutput::INSTALL_PREPARING_DESTINATION,
+ TuiOutput::INSTALL_COPYING_FILES,
+ TuiOutput::INSTALL_PREPARING_DEMO,
+ TuiOutput::INSTALL_BUILDING,
+ TuiOutput::INSTALL_BUILD_SUCCESS,
+ TuiOutput::FOOTER_FINISHED_INSTALLING,
+ TuiOutput::FOOTER_GIT_ADD,
+ TuiOutput::FOOTER_GIT_COMMIT,
+ TuiOutput::FOOTER_SITE_READY,
+ TuiOutput::FOOTER_GET_SITE_INFO,
+ TuiOutput::FOOTER_AHOY_INFO,
+ TuiOutput::INSTALL_LOGIN,
+ TuiOutput::FOOTER_AHOY_LOGIN,
+ TuiOutput::POSTBUILD_SETUP_GHA,
+ ]),
+ ...TuiOutput::absent([
+ TuiOutput::FOOTER_READY_TO_BUILD,
+ TuiOutput::FOOTER_BUILD_ERRORS,
+ ]),
+ ],
+ ],
+
+ 'Install with build flag and profile starter succeeds' => [
+ 'command_inputs' => self::tuiOptions([
+ InstallCommand::OPTION_NO_INTERACTION => TRUE,
+ InstallCommand::OPTION_BUILD => TRUE,
+ InstallCommand::OPTION_CONFIG => '{"VORTEX_STARTER":"install_profile_core"}',
+ ]),
+ 'install_executable_finder_find_callback' => fn(string $command): string => '/usr/bin/' . $command,
+ 'build_runner_exit_callback' => TuiOutput::buildRunnerSuccessProfile(),
+ 'check_requirements_runner_exit_callback' => TuiOutput::checkRequirementsSuccess(),
+ 'expect_failure' => FALSE,
+ 'output_assertions' => [
+ // Install command output - should be present.
+ ...TuiOutput::present([
+ TuiOutput::INSTALL_STARTING,
+ TuiOutput::INSTALL_DOWNLOADING,
+ TuiOutput::INSTALL_CUSTOMIZING,
+ TuiOutput::INSTALL_PREPARING_DESTINATION,
+ TuiOutput::INSTALL_COPYING_FILES,
+ TuiOutput::INSTALL_PREPARING_DEMO,
+ TuiOutput::INSTALL_BUILDING,
+ ]),
+ // Check requirements output - should be present.
+ ...TuiOutput::present([
+ TuiOutput::CHECK_REQUIREMENTS_CHECKING_DOCKER,
+ TuiOutput::CHECK_REQUIREMENTS_CHECKING_DOCKER_COMPOSE,
+ TuiOutput::CHECK_REQUIREMENTS_CHECKING_AHOY,
+ TuiOutput::CHECK_REQUIREMENTS_CHECKING_PYGMY,
+ TuiOutput::CHECK_REQUIREMENTS_DOCKER_AVAILABLE,
+ TuiOutput::CHECK_REQUIREMENTS_DOCKER_COMPOSE_AVAILABLE,
+ TuiOutput::CHECK_REQUIREMENTS_AHOY_AVAILABLE,
+ TuiOutput::CHECK_REQUIREMENTS_PYGMY_RUNNING,
+ TuiOutput::CHECK_REQUIREMENTS_ALL_MET,
+ ]),
+ // Build output (profile) - should be present.
+ ...TuiOutput::present([
+ TuiOutput::BUILD_ASSEMBLE_DOCKER,
+ TuiOutput::BUILD_ASSEMBLE_COMPOSER,
+ TuiOutput::BUILD_ASSEMBLE_YARN,
+ TuiOutput::BUILD_PROVISION_START,
+ TuiOutput::BUILD_PROVISION_PROJECT_INFO,
+ TuiOutput::BUILD_PROVISION_TYPE_PROFILE,
+ TuiOutput::BUILD_PROVISION_END,
+ ]),
+ // Final install output - should be present.
+ ...TuiOutput::present([
+ TuiOutput::INSTALL_BUILD_SUCCESS,
+ TuiOutput::FOOTER_FINISHED_INSTALLING,
+ TuiOutput::FOOTER_GIT_ADD,
+ TuiOutput::FOOTER_GIT_COMMIT,
+ TuiOutput::FOOTER_SITE_READY,
+ TuiOutput::FOOTER_GET_SITE_INFO,
+ TuiOutput::FOOTER_AHOY_INFO,
+ TuiOutput::INSTALL_LOGIN,
+ TuiOutput::FOOTER_AHOY_LOGIN,
+ TuiOutput::POSTBUILD_SETUP_GHA,
+ ]),
+ // Negative assertions - should be absent.
+ ...TuiOutput::absent([
+ TuiOutput::BUILD_PROVISION_TYPE_DB,
+ TuiOutput::INSTALL_BUILD_FAILED,
+ TuiOutput::INSTALL_EXIT_CODE,
+ TuiOutput::CHECK_REQUIREMENTS_MISSING,
+ TuiOutput::CHECK_REQUIREMENTS_DOCKER_MISSING,
+ TuiOutput::CHECK_REQUIREMENTS_DOCKER_COMPOSE_MISSING,
+ TuiOutput::CHECK_REQUIREMENTS_AHOY_MISSING,
+ TuiOutput::CHECK_REQUIREMENTS_PYGMY_NOT_RUNNING,
+ TuiOutput::FOOTER_READY_TO_BUILD,
+ TuiOutput::FOOTER_BUILD_ERRORS,
+ ]),
+ ],
+ ],
+
+ 'Install with build flag fails' => [
+ 'command_inputs' => self::tuiOptions([
+ InstallCommand::OPTION_NO_INTERACTION => TRUE,
+ InstallCommand::OPTION_BUILD => TRUE,
+ ]),
+ 'install_executable_finder_find_callback' => fn(string $command): string => '/usr/bin/' . $command,
+ 'build_runner_exit_callback' => TuiOutput::buildRunnerFailure(),
+ 'check_requirements_runner_exit_callback' => TuiOutput::checkRequirementsSuccess(),
+ 'expect_failure' => TRUE,
+ 'output_assertions' => [
+ ...TuiOutput::present([
+ TuiOutput::INSTALL_STARTING,
+ TuiOutput::INSTALL_DOWNLOADING,
+ TuiOutput::INSTALL_CUSTOMIZING,
+ TuiOutput::INSTALL_PREPARING_DESTINATION,
+ TuiOutput::INSTALL_COPYING_FILES,
+ TuiOutput::INSTALL_PREPARING_DEMO,
+ TuiOutput::INSTALL_BUILDING,
+ TuiOutput::INSTALL_BUILD_FAILED,
+ TuiOutput::FOOTER_FINISHED_INSTALLING,
+ TuiOutput::FOOTER_GIT_ADD,
+ TuiOutput::FOOTER_GIT_COMMIT,
+ TuiOutput::FOOTER_BUILD_ERRORS,
+ TuiOutput::FOOTER_BUILD_FAILED_MESSAGE,
+ TuiOutput::FOOTER_TROUBLESHOOTING,
+ TuiOutput::FOOTER_CHECK_LOGS,
+ TuiOutput::FOOTER_AHOY_BUILD,
+ TuiOutput::FOOTER_DIAGNOSTICS,
+ ]),
+ ...TuiOutput::absent([
+ TuiOutput::INSTALL_BUILD_SUCCESS,
+ TuiOutput::FOOTER_SITE_READY,
+ TuiOutput::FOOTER_READY_TO_BUILD,
+ ]),
+ ],
+ ],
+
+ 'Install with build flag and requirements of check-requirements command check fails' => [
+ 'command_inputs' => self::tuiOptions([
+ InstallCommand::OPTION_NO_INTERACTION => TRUE,
+ InstallCommand::OPTION_BUILD => TRUE,
+ ]),
+ 'install_executable_finder_find_callback' => fn(string $command): string => '/usr/bin/' . $command,
+ 'build_runner_exit_callback' => TuiOutput::buildRunnerSuccess(),
+ 'check_requirements_runner_exit_callback' => TuiOutput::checkRequirementsFailure(),
+ 'expect_failure' => TRUE,
+ 'output_assertions' => [
+ ...TuiOutput::present([
+ TuiOutput::INSTALL_STARTING,
+ TuiOutput::INSTALL_DOWNLOADING,
+ TuiOutput::INSTALL_CUSTOMIZING,
+ TuiOutput::INSTALL_PREPARING_DESTINATION,
+ TuiOutput::INSTALL_COPYING_FILES,
+ TuiOutput::INSTALL_PREPARING_DEMO,
+ TuiOutput::INSTALL_BUILDING,
+ TuiOutput::BUILD_CHECKING_REQUIREMENTS,
+ TuiOutput::CHECK_REQUIREMENTS_MISSING,
+ ]),
+ ...TuiOutput::absent([
+ TuiOutput::CHECK_REQUIREMENTS_ALL_MET,
+ TuiOutput::INSTALL_BUILD_SUCCESS,
+ ]),
+ ],
+ ],
+ ];
+ }
+
+}
diff --git a/.vortex/installer/tests/Functional/FunctionalTestCase.php b/.vortex/installer/tests/Functional/FunctionalTestCase.php
index b81edff30..b9eb232f4 100644
--- a/.vortex/installer/tests/Functional/FunctionalTestCase.php
+++ b/.vortex/installer/tests/Functional/FunctionalTestCase.php
@@ -85,14 +85,12 @@ protected function tearDown(): void {
protected function runNonInteractiveInstall(?string $dst = NULL, array $options = [], bool $expect_fail = FALSE): void {
$dst ??= static::$sut;
- if ($dst !== '' && $dst !== '0') {
- $args[InstallCommand::ARG_DESTINATION] = $dst;
- }
-
$defaults = [
InstallCommand::OPTION_NO_INTERACTION => TRUE,
InstallCommand::OPTION_URI => File::dir(static::$root),
+ InstallCommand::OPTION_DESTINATION => $dst,
];
+
$options += $defaults;
foreach ($options as $option => $value) {
@@ -110,7 +108,7 @@ protected function runInteractiveInstall(array $answers = [], ?string $dst = NUL
$this->runNonInteractiveInstall($dst, $options + [InstallCommand::OPTION_NO_INTERACTION => FALSE], $expect_fail);
}
- protected function assertSutContains(string|array $needles): void {
+ protected function assertSutContains(string | array $needles): void {
$needles = is_array($needles) ? $needles : [$needles];
foreach ($needles as $needle) {
@@ -127,7 +125,7 @@ protected function assertSutContains(string|array $needles): void {
}
}
- protected function assertSutNotContains(string|array $needles): void {
+ protected function assertSutNotContains(string | array $needles): void {
$needles = is_array($needles) ? $needles : [$needles];
foreach ($needles as $needle) {
diff --git a/.vortex/installer/tests/Functional/Handlers/AbstractHandlerProcessTestCase.php b/.vortex/installer/tests/Functional/Handlers/AbstractHandlerProcessTestCase.php
index 79ef2311f..2a774a2fa 100644
--- a/.vortex/installer/tests/Functional/Handlers/AbstractHandlerProcessTestCase.php
+++ b/.vortex/installer/tests/Functional/Handlers/AbstractHandlerProcessTestCase.php
@@ -27,6 +27,12 @@ abstract class AbstractHandlerProcessTestCase extends FunctionalTestCase {
protected function setUp(): void {
parent::setUp();
+ static::envUnsetPrefix('VORTEX_');
+ static::envUnsetPrefix('DRUPAL_');
+ static::envUnsetPrefix('LAGOON_');
+ static::envUnset('WEBROOT');
+ static::envUnset('TZ');
+
static::applicationInitFromCommand(InstallCommand::class);
// Use a two-words name for the sut directory.
@@ -36,7 +42,7 @@ protected function setUp(): void {
chdir(static::$sut);
}
- #[DataProvider('dataProviderInstall')]
+ #[DataProvider('dataProviderHandlerProcess')]
#[RunInSeparateProcess]
public function testHandlerProcess(
?SerializableClosure $before = NULL,
@@ -67,7 +73,7 @@ public function testHandlerProcess(
}
}
- abstract public static function dataProviderInstall(): array;
+ abstract public static function dataProviderHandlerProcess(): array;
protected function assertCommon(): void {
$this->assertDirectoryEqualsDirectory(static::$root . '/scripts/vortex', static::$sut . '/scripts/vortex', 'Vortex scripts were not modified.');
diff --git a/.vortex/installer/tests/Functional/Handlers/AiCodeInstructionsHandlerProcessTest.php b/.vortex/installer/tests/Functional/Handlers/AiCodeInstructionsHandlerProcessTest.php
index 6b3b527e1..5cde15eb2 100644
--- a/.vortex/installer/tests/Functional/Handlers/AiCodeInstructionsHandlerProcessTest.php
+++ b/.vortex/installer/tests/Functional/Handlers/AiCodeInstructionsHandlerProcessTest.php
@@ -12,7 +12,7 @@
#[CoversClass(AiCodeInstructions::class)]
class AiCodeInstructionsHandlerProcessTest extends AbstractHandlerProcessTestCase {
- public static function dataProviderInstall(): array {
+ public static function dataProviderHandlerProcess(): array {
return [
'ai instructions, claude' => [
static::cw(fn() => Env::put(PromptManager::makeEnvName(AiCodeInstructions::id()), AiCodeInstructions::CLAUDE)),
diff --git a/.vortex/installer/tests/Functional/Handlers/BaselineHandlerProcessTest.php b/.vortex/installer/tests/Functional/Handlers/BaselineHandlerProcessTest.php
index f20775b09..3dbcfd9a6 100644
--- a/.vortex/installer/tests/Functional/Handlers/BaselineHandlerProcessTest.php
+++ b/.vortex/installer/tests/Functional/Handlers/BaselineHandlerProcessTest.php
@@ -65,7 +65,7 @@
#[CoversClass(Tui::class)]
class BaselineHandlerProcessTest extends AbstractHandlerProcessTestCase {
- public static function dataProviderInstall(): array {
+ public static function dataProviderHandlerProcess(): array {
return [
static::BASELINE_DATASET => [
NULL,
diff --git a/.vortex/installer/tests/Functional/Handlers/CiProviderHandlerProcessTest.php b/.vortex/installer/tests/Functional/Handlers/CiProviderHandlerProcessTest.php
index 286ae9bfb..c5f33072e 100644
--- a/.vortex/installer/tests/Functional/Handlers/CiProviderHandlerProcessTest.php
+++ b/.vortex/installer/tests/Functional/Handlers/CiProviderHandlerProcessTest.php
@@ -13,7 +13,7 @@
#[CoversClass(CiProvider::class)]
class CiProviderHandlerProcessTest extends AbstractHandlerProcessTestCase {
- public static function dataProviderInstall(): array {
+ public static function dataProviderHandlerProcess(): array {
return [
'ciprovider, gha' => [
static::cw(function (): void {
diff --git a/.vortex/installer/tests/Functional/Handlers/CodeProviderHandlerProcessTest.php b/.vortex/installer/tests/Functional/Handlers/CodeProviderHandlerProcessTest.php
index fc61f6864..fdd088570 100644
--- a/.vortex/installer/tests/Functional/Handlers/CodeProviderHandlerProcessTest.php
+++ b/.vortex/installer/tests/Functional/Handlers/CodeProviderHandlerProcessTest.php
@@ -13,7 +13,7 @@
#[CoversClass(CodeProvider::class)]
class CodeProviderHandlerProcessTest extends AbstractHandlerProcessTestCase {
- public static function dataProviderInstall(): array {
+ public static function dataProviderHandlerProcess(): array {
return [
'code provider, github' => [
static::cw(fn() => Env::put(PromptManager::makeEnvName(CodeProvider::id()), CodeProvider::GITHUB)),
diff --git a/.vortex/installer/tests/Functional/Handlers/DatabaseDownloadSourceHandlerProcessTest.php b/.vortex/installer/tests/Functional/Handlers/DatabaseDownloadSourceHandlerProcessTest.php
index cda90c9e7..0a3e980b2 100644
--- a/.vortex/installer/tests/Functional/Handlers/DatabaseDownloadSourceHandlerProcessTest.php
+++ b/.vortex/installer/tests/Functional/Handlers/DatabaseDownloadSourceHandlerProcessTest.php
@@ -15,7 +15,7 @@
#[CoversClass(DatabaseImage::class)]
class DatabaseDownloadSourceHandlerProcessTest extends AbstractHandlerProcessTestCase {
- public static function dataProviderInstall(): array {
+ public static function dataProviderHandlerProcess(): array {
return [
'db download source, url' => [
static::cw(fn() => Env::put(PromptManager::makeEnvName(DatabaseDownloadSource::id()), DatabaseDownloadSource::URL)),
diff --git a/.vortex/installer/tests/Functional/Handlers/DependencyUpdatesProviderHandlerProcessTest.php b/.vortex/installer/tests/Functional/Handlers/DependencyUpdatesProviderHandlerProcessTest.php
index 35e77f19c..e23549692 100644
--- a/.vortex/installer/tests/Functional/Handlers/DependencyUpdatesProviderHandlerProcessTest.php
+++ b/.vortex/installer/tests/Functional/Handlers/DependencyUpdatesProviderHandlerProcessTest.php
@@ -13,7 +13,7 @@
#[CoversClass(DependencyUpdatesProvider::class)]
class DependencyUpdatesProviderHandlerProcessTest extends AbstractHandlerProcessTestCase {
- public static function dataProviderInstall(): array {
+ public static function dataProviderHandlerProcess(): array {
return [
'deps updates provider, ci, gha' => [
static::cw(fn() => Env::put(PromptManager::makeEnvName(DependencyUpdatesProvider::id()), DependencyUpdatesProvider::RENOVATEBOT_CI)),
diff --git a/.vortex/installer/tests/Functional/Handlers/DeployTypeHandlerProcessTest.php b/.vortex/installer/tests/Functional/Handlers/DeployTypeHandlerProcessTest.php
index de7639e10..de82cb18e 100644
--- a/.vortex/installer/tests/Functional/Handlers/DeployTypeHandlerProcessTest.php
+++ b/.vortex/installer/tests/Functional/Handlers/DeployTypeHandlerProcessTest.php
@@ -14,7 +14,7 @@
#[CoversClass(DeployTypes::class)]
class DeployTypeHandlerProcessTest extends AbstractHandlerProcessTestCase {
- public static function dataProviderInstall(): array {
+ public static function dataProviderHandlerProcess(): array {
return [
'deploy types, artifact' => [
static::cw(fn() => Env::put(PromptManager::makeEnvName(DeployTypes::id()), Converter::toList([DeployTypes::ARTIFACT], ',', TRUE))),
diff --git a/.vortex/installer/tests/Functional/Handlers/DocsHandlerProcessTest.php b/.vortex/installer/tests/Functional/Handlers/DocsHandlerProcessTest.php
index 189b66975..c7b9ce8ef 100644
--- a/.vortex/installer/tests/Functional/Handlers/DocsHandlerProcessTest.php
+++ b/.vortex/installer/tests/Functional/Handlers/DocsHandlerProcessTest.php
@@ -12,7 +12,7 @@
#[CoversClass(PreserveDocsProject::class)]
class DocsHandlerProcessTest extends AbstractHandlerProcessTestCase {
- public static function dataProviderInstall(): array {
+ public static function dataProviderHandlerProcess(): array {
return [
'preserve docs project, enabled' => [
static::cw(fn() => Env::put(PromptManager::makeEnvName(PreserveDocsProject::id()), Env::TRUE)),
diff --git a/.vortex/installer/tests/Functional/Handlers/HostingProjectNameHandlerProcessTest.php b/.vortex/installer/tests/Functional/Handlers/HostingProjectNameHandlerProcessTest.php
index c04919ff0..d227969d7 100644
--- a/.vortex/installer/tests/Functional/Handlers/HostingProjectNameHandlerProcessTest.php
+++ b/.vortex/installer/tests/Functional/Handlers/HostingProjectNameHandlerProcessTest.php
@@ -14,7 +14,7 @@
#[CoversClass(HostingProjectName::class)]
class HostingProjectNameHandlerProcessTest extends AbstractHandlerProcessTestCase {
- public static function dataProviderInstall(): array {
+ public static function dataProviderHandlerProcess(): array {
return [
'hosting project name - acquia' => [
static::cw(function (): void {
diff --git a/.vortex/installer/tests/Functional/Handlers/HostingProviderHandlerProcessTest.php b/.vortex/installer/tests/Functional/Handlers/HostingProviderHandlerProcessTest.php
index 2b26aaf01..14d332218 100644
--- a/.vortex/installer/tests/Functional/Handlers/HostingProviderHandlerProcessTest.php
+++ b/.vortex/installer/tests/Functional/Handlers/HostingProviderHandlerProcessTest.php
@@ -14,7 +14,7 @@
#[CoversClass(HostingProvider::class)]
class HostingProviderHandlerProcessTest extends AbstractHandlerProcessTestCase {
- public static function dataProviderInstall(): array {
+ public static function dataProviderHandlerProcess(): array {
return [
'hosting, acquia' => [
static::cw(function (): void {
diff --git a/.vortex/installer/tests/Functional/Handlers/ModulesHandlerProcessTest.php b/.vortex/installer/tests/Functional/Handlers/ModulesHandlerProcessTest.php
index 8267e12ce..33cefdf25 100644
--- a/.vortex/installer/tests/Functional/Handlers/ModulesHandlerProcessTest.php
+++ b/.vortex/installer/tests/Functional/Handlers/ModulesHandlerProcessTest.php
@@ -14,7 +14,7 @@
#[CoversClass(Modules::class)]
class ModulesHandlerProcessTest extends AbstractHandlerProcessTestCase {
- public static function dataProviderInstall(): array {
+ public static function dataProviderHandlerProcess(): array {
return [
'modules, no admin_toolbar' => [
static::cw(function (): void {
diff --git a/.vortex/installer/tests/Functional/Handlers/NamesHandlerProcessTest.php b/.vortex/installer/tests/Functional/Handlers/NamesHandlerProcessTest.php
index b2a857c23..50ab13c3b 100644
--- a/.vortex/installer/tests/Functional/Handlers/NamesHandlerProcessTest.php
+++ b/.vortex/installer/tests/Functional/Handlers/NamesHandlerProcessTest.php
@@ -18,7 +18,7 @@
#[CoversClass(Name::class)]
class NamesHandlerProcessTest extends AbstractHandlerProcessTestCase {
- public static function dataProviderInstall(): array {
+ public static function dataProviderHandlerProcess(): array {
return [
'names' => [
static::cw(function (): void {
diff --git a/.vortex/installer/tests/Functional/Handlers/NotificationChannelsHandlerProcessTest.php b/.vortex/installer/tests/Functional/Handlers/NotificationChannelsHandlerProcessTest.php
index dee3580e7..23076b1ff 100644
--- a/.vortex/installer/tests/Functional/Handlers/NotificationChannelsHandlerProcessTest.php
+++ b/.vortex/installer/tests/Functional/Handlers/NotificationChannelsHandlerProcessTest.php
@@ -14,7 +14,7 @@
#[CoversClass(NotificationChannels::class)]
class NotificationChannelsHandlerProcessTest extends AbstractHandlerProcessTestCase {
- public static function dataProviderInstall(): array {
+ public static function dataProviderHandlerProcess(): array {
return [
'notification_channels, all' => [
static::cw(function (): void {
diff --git a/.vortex/installer/tests/Functional/Handlers/ProfileHandlerProcessTest.php b/.vortex/installer/tests/Functional/Handlers/ProfileHandlerProcessTest.php
index 954df2341..343133511 100644
--- a/.vortex/installer/tests/Functional/Handlers/ProfileHandlerProcessTest.php
+++ b/.vortex/installer/tests/Functional/Handlers/ProfileHandlerProcessTest.php
@@ -12,7 +12,7 @@
#[CoversClass(Profile::class)]
class ProfileHandlerProcessTest extends AbstractHandlerProcessTestCase {
- public static function dataProviderInstall(): array {
+ public static function dataProviderHandlerProcess(): array {
return [
'profile, minimal' => [
static::cw(fn() => Env::put(PromptManager::makeEnvName(Profile::id()), Profile::MINIMAL)),
diff --git a/.vortex/installer/tests/Functional/Handlers/ProvisionTypeHandlerProcessTest.php b/.vortex/installer/tests/Functional/Handlers/ProvisionTypeHandlerProcessTest.php
index 544a69044..be9bb4419 100644
--- a/.vortex/installer/tests/Functional/Handlers/ProvisionTypeHandlerProcessTest.php
+++ b/.vortex/installer/tests/Functional/Handlers/ProvisionTypeHandlerProcessTest.php
@@ -14,7 +14,7 @@
#[CoversClass(ProvisionType::class)]
class ProvisionTypeHandlerProcessTest extends AbstractHandlerProcessTestCase {
- public static function dataProviderInstall(): array {
+ public static function dataProviderHandlerProcess(): array {
return [
'provision, database' => [
static::cw(fn() => Env::put(PromptManager::makeEnvName(ProvisionType::id()), ProvisionType::DATABASE)),
diff --git a/.vortex/installer/tests/Functional/Handlers/PullRequestHandlerProcessTest.php b/.vortex/installer/tests/Functional/Handlers/PullRequestHandlerProcessTest.php
index d9a17c99d..6a887ff62 100644
--- a/.vortex/installer/tests/Functional/Handlers/PullRequestHandlerProcessTest.php
+++ b/.vortex/installer/tests/Functional/Handlers/PullRequestHandlerProcessTest.php
@@ -14,7 +14,7 @@
#[CoversClass(LabelMergeConflictsPr::class)]
class PullRequestHandlerProcessTest extends AbstractHandlerProcessTestCase {
- public static function dataProviderInstall(): array {
+ public static function dataProviderHandlerProcess(): array {
return [
'assign author PR, enabled' => [
static::cw(fn() => Env::put(PromptManager::makeEnvName(AssignAuthorPr::id()), Env::TRUE)),
diff --git a/.vortex/installer/tests/Functional/Handlers/ServicesHandlerProcessTest.php b/.vortex/installer/tests/Functional/Handlers/ServicesHandlerProcessTest.php
index 28ffb16c8..5682ebf7c 100644
--- a/.vortex/installer/tests/Functional/Handlers/ServicesHandlerProcessTest.php
+++ b/.vortex/installer/tests/Functional/Handlers/ServicesHandlerProcessTest.php
@@ -15,7 +15,7 @@
#[CoversClass(Services::class)]
class ServicesHandlerProcessTest extends AbstractHandlerProcessTestCase {
- public static function dataProviderInstall(): array {
+ public static function dataProviderHandlerProcess(): array {
return [
'services, no clamav' => [
static::cw(function (): void {
diff --git a/.vortex/installer/tests/Functional/Handlers/StarterHandlerProcessTest.php b/.vortex/installer/tests/Functional/Handlers/StarterHandlerProcessTest.php
index c36881ae7..8c6b2400b 100644
--- a/.vortex/installer/tests/Functional/Handlers/StarterHandlerProcessTest.php
+++ b/.vortex/installer/tests/Functional/Handlers/StarterHandlerProcessTest.php
@@ -13,7 +13,7 @@
#[CoversClass(Starter::class)]
class StarterHandlerProcessTest extends AbstractHandlerProcessTestCase {
- public static function dataProviderInstall(): array {
+ public static function dataProviderHandlerProcess(): array {
return [
'starter, demo db' => [
static::cw(fn() => Env::put(PromptManager::makeEnvName(Starter::id()), Starter::LOAD_DATABASE_DEMO)),
diff --git a/.vortex/installer/tests/Functional/Handlers/ThemeHandlerProcessTest.php b/.vortex/installer/tests/Functional/Handlers/ThemeHandlerProcessTest.php
index 6b9c1a9c5..96d9a83af 100644
--- a/.vortex/installer/tests/Functional/Handlers/ThemeHandlerProcessTest.php
+++ b/.vortex/installer/tests/Functional/Handlers/ThemeHandlerProcessTest.php
@@ -13,7 +13,7 @@
#[CoversClass(Theme::class)]
class ThemeHandlerProcessTest extends AbstractHandlerProcessTestCase {
- public static function dataProviderInstall(): array {
+ public static function dataProviderHandlerProcess(): array {
return [
'theme, olivero' => [
static::cw(fn() => Env::put(PromptManager::makeEnvName(Theme::id()), Theme::OLIVERO)),
diff --git a/.vortex/installer/tests/Functional/Handlers/TimezoneHandlerProcessTest.php b/.vortex/installer/tests/Functional/Handlers/TimezoneHandlerProcessTest.php
index f7e5f23c9..ae9ab473a 100644
--- a/.vortex/installer/tests/Functional/Handlers/TimezoneHandlerProcessTest.php
+++ b/.vortex/installer/tests/Functional/Handlers/TimezoneHandlerProcessTest.php
@@ -14,7 +14,7 @@
#[CoversClass(Timezone::class)]
class TimezoneHandlerProcessTest extends AbstractHandlerProcessTestCase {
- public static function dataProviderInstall(): array {
+ public static function dataProviderHandlerProcess(): array {
return [
'timezone, gha' => [
diff --git a/.vortex/installer/tests/Functional/Handlers/ToolsHandlerProcessTest.php b/.vortex/installer/tests/Functional/Handlers/ToolsHandlerProcessTest.php
index b5a6945e0..3942f83c8 100644
--- a/.vortex/installer/tests/Functional/Handlers/ToolsHandlerProcessTest.php
+++ b/.vortex/installer/tests/Functional/Handlers/ToolsHandlerProcessTest.php
@@ -15,7 +15,7 @@
#[CoversClass(Tools::class)]
class ToolsHandlerProcessTest extends AbstractHandlerProcessTestCase {
- public static function dataProviderInstall(): array {
+ public static function dataProviderHandlerProcess(): array {
return [
'tools, none' => [
static::cw(function (): void {
diff --git a/.vortex/installer/tests/Functional/Handlers/VersionSchemeHandlerProcessTest.php b/.vortex/installer/tests/Functional/Handlers/VersionSchemeHandlerProcessTest.php
index 750f2b1c7..24bc09638 100644
--- a/.vortex/installer/tests/Functional/Handlers/VersionSchemeHandlerProcessTest.php
+++ b/.vortex/installer/tests/Functional/Handlers/VersionSchemeHandlerProcessTest.php
@@ -13,7 +13,7 @@
#[CoversClass(VersionScheme::class)]
class VersionSchemeHandlerProcessTest extends AbstractHandlerProcessTestCase {
- public static function dataProviderInstall(): array {
+ public static function dataProviderHandlerProcess(): array {
return [
'version scheme, calver' => [
static::cw(function (): void {
diff --git a/.vortex/installer/tests/Functional/PharTest.php b/.vortex/installer/tests/Functional/PharTest.php
index f42cb7bc4..e71283d78 100644
--- a/.vortex/installer/tests/Functional/PharTest.php
+++ b/.vortex/installer/tests/Functional/PharTest.php
@@ -76,7 +76,7 @@ public function testPharOptionHelp(): void {
$this->runInstallationWithPhar($this->pharFile, ['help' => TRUE]);
$this->assertProcessSuccessful();
- $this->assertProcessOutputContains('Vortex Installer');
+ $this->assertProcessOutputContains('Install Vortex from remote or local repository');
$this->assertProcessOutputNotContains('Welcome to the Vortex non-interactive installer');
$this->assertFileDoesNotExist(static::$sut . DIRECTORY_SEPARATOR . 'composer.json', 'Composer file should NOT be created when --help flag is used');
$this->assertFileExists($this->pharFile, 'PHAR file should NOT be removed when --help option is used');
@@ -103,10 +103,11 @@ protected static function buildPhar(string $dst): void {
}
protected function runInstallationWithPhar(string $phar_path, array $options = [], array $inputs = []): void {
- $arguments = [$phar_path, static::$sut];
+ $arguments = [$phar_path];
$defaults = [
- 'uri' => File::dir(static::$root),
+ InstallCommand::OPTION_DESTINATION => static::$sut,
+ InstallCommand::OPTION_URI => File::dir(static::$root),
];
$options += $defaults;
diff --git a/.vortex/installer/tests/Helpers/TuiOutput.php b/.vortex/installer/tests/Helpers/TuiOutput.php
new file mode 100644
index 000000000..39721473d
--- /dev/null
+++ b/.vortex/installer/tests/Helpers/TuiOutput.php
@@ -0,0 +1,312 @@
+ $constants
+ * Array of constant values.
+ *
+ * @return array
+ * Array with '* ' prefix added to each constant.
+ */
+ public static function present(array $constants): array {
+ return array_map(fn($c) => '* ' . $c, $constants);
+ }
+
+ /**
+ * Mark constants as absent (should NOT contain in output).
+ *
+ * @param array $constants
+ * Array of constant values.
+ *
+ * @return array
+ * Array with '! ' prefix added to each constant.
+ */
+ public static function absent(array $constants): array {
+ return array_map(fn($c) => '! ' . $c, $constants);
+ }
+
+ /**
+ * Echo constants as output lines.
+ *
+ * @param array $constants
+ * Array of constant values to echo.
+ */
+ public static function echo(array $constants): void {
+ foreach ($constants as $constant) {
+ echo $constant . PHP_EOL;
+ }
+ }
+
+ /**
+ * Create a successful build runner callback.
+ *
+ * Simulates a successful build with database provisioning.
+ *
+ * @return \Closure
+ * Closure that echoes build output and returns success exit code.
+ */
+ public static function buildRunnerSuccess(): \Closure {
+ return function (string $command): int {
+ self::echo([
+ self::BUILD_ASSEMBLE_DOCKER,
+ self::BUILD_ASSEMBLE_COMPOSER,
+ self::BUILD_ASSEMBLE_YARN,
+ self::BUILD_PROVISION_START,
+ self::BUILD_PROVISION_PROJECT_INFO,
+ self::BUILD_PROVISION_TYPE_DB,
+ self::BUILD_PROVISION_END,
+ ]);
+ return RunnerInterface::EXIT_SUCCESS;
+ };
+ }
+
+ /**
+ * Create a successful build runner callback with profile provisioning.
+ *
+ * Simulates a successful build using install profile instead of database.
+ *
+ * @return \Closure
+ * Closure that echoes build output and returns success exit code.
+ */
+ public static function buildRunnerSuccessProfile(): \Closure {
+ return function (string $command): int {
+ self::echo([
+ self::BUILD_ASSEMBLE_DOCKER,
+ self::BUILD_ASSEMBLE_COMPOSER,
+ self::BUILD_ASSEMBLE_YARN,
+ self::BUILD_PROVISION_START,
+ self::BUILD_PROVISION_PROJECT_INFO,
+ self::BUILD_PROVISION_TYPE_PROFILE,
+ self::BUILD_PROVISION_END,
+ ]);
+ return RunnerInterface::EXIT_SUCCESS;
+ };
+ }
+
+ /**
+ * Create a failed build runner callback.
+ *
+ * Simulates a build that starts but fails during provisioning.
+ *
+ * @return \Closure
+ * Closure that echoes partial build output and returns failure exit code.
+ */
+ public static function buildRunnerFailure(): \Closure {
+ return function (string $command): int {
+ self::echo([
+ self::BUILD_ASSEMBLE_DOCKER,
+ self::BUILD_ASSEMBLE_COMPOSER,
+ self::BUILD_ASSEMBLE_YARN,
+ self::BUILD_PROVISION_START,
+ ]);
+ return RunnerInterface::EXIT_FAILURE;
+ };
+ }
+
+ /**
+ * Create a successful check requirements callback.
+ *
+ * Simulates all requirements checks passing.
+ *
+ * @return \Closure
+ * Closure that echoes requirements check output and returns success.
+ */
+ public static function checkRequirementsSuccess(): \Closure {
+ return function (string $command): int {
+ self::echo([
+ self::CHECK_REQUIREMENTS_CHECKING_DOCKER,
+ self::CHECK_REQUIREMENTS_DOCKER_AVAILABLE,
+ self::CHECK_REQUIREMENTS_CHECKING_DOCKER_COMPOSE,
+ self::CHECK_REQUIREMENTS_DOCKER_COMPOSE_AVAILABLE,
+ self::CHECK_REQUIREMENTS_CHECKING_AHOY,
+ self::CHECK_REQUIREMENTS_AHOY_AVAILABLE,
+ self::CHECK_REQUIREMENTS_CHECKING_PYGMY,
+ self::CHECK_REQUIREMENTS_PYGMY_RUNNING,
+ self::CHECK_REQUIREMENTS_ALL_MET,
+ ]);
+ return RunnerInterface::EXIT_SUCCESS;
+ };
+ }
+
+ /**
+ * Create a failed check requirements callback.
+ *
+ * Simulates requirements checks with missing tools.
+ *
+ * @return \Closure
+ * Closure that echoes requirements check output and returns failure.
+ */
+ public static function checkRequirementsFailure(): \Closure {
+ return function (string $command): int {
+ self::echo([
+ self::CHECK_REQUIREMENTS_CHECKING_DOCKER,
+ self::CHECK_REQUIREMENTS_DOCKER_AVAILABLE,
+ self::CHECK_REQUIREMENTS_CHECKING_DOCKER_COMPOSE,
+ self::CHECK_REQUIREMENTS_DOCKER_COMPOSE_MISSING,
+ self::CHECK_REQUIREMENTS_MISSING,
+ ]);
+ return RunnerInterface::EXIT_FAILURE;
+ };
+ }
+
+}
diff --git a/.vortex/installer/tests/Traits/TuiTrait.php b/.vortex/installer/tests/Traits/TuiTrait.php
index 7c8bfb597..82188e4c8 100644
--- a/.vortex/installer/tests/Traits/TuiTrait.php
+++ b/.vortex/installer/tests/Traits/TuiTrait.php
@@ -34,4 +34,21 @@ protected static function tuiTeardown(): void {
Prompt::validateUsing(NULL);
}
+ /**
+ * Helper to create command options array with '--' prefix.
+ *
+ * @param array $options
+ * Array of option constants as keys and their values.
+ *
+ * @return array
+ * Array with '--' prefix added to each option key.
+ */
+ protected static function tuiOptions(array $options): array {
+ $result = [];
+ foreach ($options as $option => $value) {
+ $result['--' . $option] = $value;
+ }
+ return $result;
+ }
+
}
diff --git a/.vortex/installer/tests/Unit/ConfigTest.php b/.vortex/installer/tests/Unit/ConfigTest.php
index 62ac1ef81..50dca04ea 100644
--- a/.vortex/installer/tests/Unit/ConfigTest.php
+++ b/.vortex/installer/tests/Unit/ConfigTest.php
@@ -218,7 +218,7 @@ public static function dataProviderIsQuiet(): array {
'boolean_true' => [TRUE, TRUE],
'boolean_false' => [FALSE, FALSE],
'string_true' => ['true', TRUE],
- // Non-empty string is truthy.
+ // Non-empty string is truthy.
'string_false' => ['false', TRUE],
'string_empty' => ['', FALSE],
'integer_zero' => [0, FALSE],
@@ -256,7 +256,7 @@ public static function dataProviderGetNoInteraction(): array {
'boolean_true' => [TRUE, TRUE],
'boolean_false' => [FALSE, FALSE],
'string_true' => ['true', TRUE],
- // Non-empty string is truthy.
+ // Non-empty string is truthy.
'string_false' => ['false', TRUE],
'string_empty' => ['', FALSE],
'integer_zero' => [0, FALSE],
@@ -294,7 +294,7 @@ public static function dataProviderIsVortexProject(): array {
'boolean_true' => [TRUE, TRUE],
'boolean_false' => [FALSE, FALSE],
'string_true' => ['true', TRUE],
- // Non-empty string is truthy.
+ // Non-empty string is truthy.
'string_false' => ['false', TRUE],
'string_empty' => ['', FALSE],
'integer_zero' => [0, FALSE],
@@ -318,6 +318,7 @@ public function testConstants(): void {
$this->assertEquals('VORTEX_INSTALLER_NO_INTERACTION', Config::NO_INTERACTION);
$this->assertEquals('VORTEX_INSTALLER_QUIET', Config::QUIET);
$this->assertEquals('VORTEX_INSTALLER_NO_CLEANUP', Config::NO_CLEANUP);
+ $this->assertEquals('VORTEX_INSTALLER_BUILD_NOW', Config::BUILD_NOW);
}
public function testEnvironmentVariablePrecedenceInConstructor(): void {
diff --git a/.vortex/installer/tests/Unit/Logger/FileLoggerTest.php b/.vortex/installer/tests/Unit/Logger/FileLoggerTest.php
new file mode 100644
index 000000000..70580dd95
--- /dev/null
+++ b/.vortex/installer/tests/Unit/Logger/FileLoggerTest.php
@@ -0,0 +1,390 @@
+disable();
+ }
+
+ $this->assertEquals($initial_state, $logger->isEnabled());
+
+ $result = $logger->enable();
+ $this->assertEquals($after_enable, $logger->isEnabled());
+ $this->assertInstanceOf(FileLogger::class, $result, 'enable() should return self for method chaining');
+
+ $result = $logger->disable();
+ $this->assertEquals($after_disable, $logger->isEnabled());
+ $this->assertInstanceOf(FileLogger::class, $result, 'disable() should return self for method chaining');
+ }
+
+ /**
+ * Test setDir and getDir methods.
+ */
+ #[DataProvider('dataProviderDirectoryManagement')]
+ public function testDirectoryManagement(string $dir, bool $test_default): void {
+ $logger = new FileLogger();
+
+ // Test default directory uses getcwd().
+ if ($test_default) {
+ $this->assertEquals(getcwd(), $logger->getDir());
+ }
+ else {
+ // Test setDir sets custom directory.
+ $result = $logger->setDir($dir);
+ $this->assertEquals($dir, $logger->getDir());
+ $this->assertInstanceOf(FileLogger::class, $result, 'setDir() should return self for method chaining');
+
+ // Test getDir returns the set directory.
+ $this->assertEquals($dir, $logger->getDir());
+ }
+ }
+
+ /**
+ * Test open method with enabled logging.
+ */
+ #[DataProvider('dataProviderOpen')]
+ public function testOpen(string $command, array $args, bool $enabled, ?string $expected_pattern, ?string $expected_exception, ?string $expected_message): void {
+ if ($expected_exception !== NULL) {
+ /** @var class-string<\Throwable> $expected_exception */
+ $this->expectException($expected_exception);
+ $this->expectExceptionMessage($expected_message ?? '');
+ }
+
+ $logger = new FileLogger();
+ $logger->setDir(self::$tmp);
+
+ if (!$enabled) {
+ $logger->disable();
+ }
+
+ $result = $logger->open($command, $args);
+
+ if (!$enabled) {
+ $this->assertFalse($result, 'open() should return FALSE when logger is disabled');
+ $this->assertNull($logger->getPath(), 'getPath() should return NULL when logger is disabled');
+ }
+ else {
+ $this->assertTrue($result, 'open() should return TRUE when logger is enabled');
+ $path = $logger->getPath();
+ $this->assertNotNull($path, 'getPath() should return path after successful open()');
+
+ if ($expected_pattern !== NULL) {
+ $this->assertMatchesRegularExpression($expected_pattern, $path, 'Log file path should match expected pattern');
+ }
+
+ // Verify log directory was created.
+ $log_dir = dirname($path);
+ $this->assertDirectoryExists($log_dir, 'Log directory should be created');
+
+ // Verify log file was created.
+ $this->assertFileExists($path, 'Log file should be created');
+
+ $logger->close();
+ File::remove($path);
+ }
+ }
+
+ /**
+ * Test write method.
+ */
+ #[DataProvider('dataProviderWrite')]
+ public function testWrite(string $content, bool $is_open, int $expected_writes): void {
+ $logger = new FileLogger();
+ $logger->setDir(self::$tmp);
+
+ if ($is_open) {
+ $logger->open('test-command');
+ $path = $logger->getPath();
+ $this->assertNotNull($path);
+ }
+
+ // Write content multiple times.
+ for ($i = 0; $i < $expected_writes; $i++) {
+ $logger->write($content);
+ }
+
+ if ($is_open) {
+ $logger->close();
+ $path = $logger->getPath();
+
+ // Verify content was written.
+ $written_content = file_get_contents((string) $path);
+ $expected_content = str_repeat($content, $expected_writes);
+ $this->assertEquals($expected_content, $written_content, 'Written content should match expected content');
+
+ File::remove((string) $path);
+ }
+ else {
+ // When logger is not open, write() should be a no-op.
+ // We can't directly verify this, but we ensure no errors occur.
+ // @phpstan-ignore-next-line
+ $this->assertTrue(TRUE, 'write() should not throw error when logger is not open');
+ }
+ }
+
+ /**
+ * Test close method.
+ */
+ public function testClose(): void {
+ $logger = new FileLogger();
+ $logger->setDir(self::$tmp);
+
+ // Test close when no file is open (should be no-op).
+ $logger->close();
+ // @phpstan-ignore-next-line
+ $this->assertTrue(TRUE, 'close() should not throw error when no file is open');
+
+ // Test close after opening.
+ $logger->open('test-command');
+ $path = $logger->getPath();
+ $this->assertNotNull($path);
+
+ $logger->close();
+
+ // Verify file is closed by attempting to write (should be no-op).
+ $logger->write('should not be written');
+
+ // File should still exist but content should not be written after close.
+ $content = file_get_contents($path);
+ $this->assertEquals('', $content, 'No content should be written after close()');
+
+ // Test multiple close calls (idempotent).
+ $logger->close();
+ $logger->close();
+ // @phpstan-ignore-next-line
+ $this->assertTrue(TRUE, 'Multiple close() calls should not throw error');
+
+ File::remove($path);
+ }
+
+ /**
+ * Test getPath method.
+ */
+ public function testGetPath(): void {
+ $logger = new FileLogger();
+ $logger->setDir(self::$tmp);
+
+ // Test getPath before open() is called.
+ $this->assertNull($logger->getPath(), 'getPath() should return NULL before open() is called');
+
+ // Test getPath after open().
+ $logger->open('test-command');
+ $path = $logger->getPath();
+ // @phpstan-ignore-next-line
+ $this->assertNotNull($path, 'getPath() should return path after open()');
+ $this->assertStringContainsString('test-command', (string) $path, 'Path should contain command name');
+
+ $logger->close();
+ File::remove((string) $path);
+
+ // Test getPath when logging is disabled before open.
+ $logger2 = new FileLogger();
+ $logger2->setDir(self::$tmp);
+ $logger2->disable();
+ $result = $logger2->open('test-command-disabled');
+ $this->assertFalse($result, 'open() should return FALSE when disabled');
+ $this->assertNull($logger2->getPath(), 'getPath() should return NULL when logging is disabled');
+ }
+
+ /**
+ * Test buildFilename method.
+ */
+ #[DataProvider('dataProviderBuildFilename')]
+ public function testBuildFilename(string $command, array $args, string $expected): void {
+ $logger = new FileLogger();
+ $logger->setDir(self::$tmp);
+
+ $logger->open($command, $args);
+ $path = $logger->getPath();
+
+ if ($path !== NULL) {
+ $filename = basename($path, '.log');
+ // Remove timestamp suffix (format: -YYYY-MM-DD-HHMMSS).
+ $filename = (string) preg_replace('/-\d{4}-\d{2}-\d{2}-\d{6}$/', '', $filename);
+
+ $this->assertEquals($expected, $filename, 'Filename should match expected pattern');
+
+ $logger->close();
+ File::remove($path);
+ }
+ }
+
+ /**
+ * Data provider for enable/disable tests.
+ */
+ public static function dataProviderEnableDisable(): array {
+ return [
+ 'initially enabled' => [
+ 'initial_state' => TRUE,
+ 'after_enable' => TRUE,
+ 'after_disable' => FALSE,
+ ],
+ 'initially disabled' => [
+ 'initial_state' => FALSE,
+ 'after_enable' => TRUE,
+ 'after_disable' => FALSE,
+ ],
+ ];
+ }
+
+ /**
+ * Data provider for directory paths.
+ */
+ public static function dataProviderDirectoryManagement(): array {
+ return [
+ 'default directory (cwd)' => [
+ 'dir' => '',
+ 'test_default' => TRUE,
+ ],
+ 'absolute path' => [
+ 'dir' => '/tmp/test-dir',
+ 'test_default' => FALSE,
+ ],
+ 'relative path' => [
+ 'dir' => './test-dir',
+ 'test_default' => FALSE,
+ ],
+ ];
+ }
+
+ /**
+ * Data provider for open scenarios.
+ */
+ public static function dataProviderOpen(): array {
+ return [
+ 'simple command, enabled' => [
+ 'command' => 'test-command',
+ 'args' => [],
+ 'enabled' => TRUE,
+ 'expected_pattern' => '/test-command-\d{4}-\d{2}-\d{2}-\d{6}\.log$/',
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with positional args' => [
+ 'command' => 'install',
+ 'args' => ['project', 'arg2'],
+ 'enabled' => TRUE,
+ 'expected_pattern' => '/install-project-arg2-\d{4}-\d{2}-\d{2}-\d{6}\.log$/',
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with option args (filtered)' => [
+ 'command' => 'test',
+ 'args' => ['positional', '--option=value', '-f'],
+ 'enabled' => TRUE,
+ 'expected_pattern' => '/test-positional-\d{4}-\d{2}-\d{2}-\d{6}\.log$/',
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with special characters' => [
+ 'command' => 'test:command',
+ 'args' => ['arg/with/slashes', 'arg with spaces'],
+ 'enabled' => TRUE,
+ 'expected_pattern' => '/test-command-arg-with-slashes-arg-with-spaces-\d{4}-\d{2}-\d{2}-\d{6}\.log$/',
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'disabled logger' => [
+ 'command' => 'test-disabled',
+ 'args' => [],
+ 'enabled' => FALSE,
+ 'expected_pattern' => NULL,
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ ];
+ }
+
+ /**
+ * Data provider for write content.
+ */
+ public static function dataProviderWrite(): array {
+ return [
+ 'single write, logger open' => [
+ 'content' => 'Test log entry',
+ 'is_open' => TRUE,
+ 'expected_writes' => 1,
+ ],
+ 'multiple writes, logger open' => [
+ 'content' => 'Line of text',
+ 'is_open' => TRUE,
+ 'expected_writes' => 3,
+ ],
+ 'empty content, logger open' => [
+ 'content' => '',
+ 'is_open' => TRUE,
+ 'expected_writes' => 1,
+ ],
+ 'multiline content, logger open' => [
+ 'content' => "Line 1\nLine 2\nLine 3\n",
+ 'is_open' => TRUE,
+ 'expected_writes' => 1,
+ ],
+ 'write when logger not open (no-op)' => [
+ 'content' => 'Should not be written',
+ 'is_open' => FALSE,
+ 'expected_writes' => 1,
+ ],
+ ];
+ }
+
+ /**
+ * Data provider for filename building.
+ */
+ public static function dataProviderBuildFilename(): array {
+ return [
+ 'command only' => [
+ 'command' => 'test-command',
+ 'args' => [],
+ 'expected' => 'test-command',
+ ],
+ 'command with positional args' => [
+ 'command' => 'install',
+ 'args' => ['project', 'theme'],
+ 'expected' => 'install-project-theme',
+ ],
+ 'command with options (filtered)' => [
+ 'command' => 'run',
+ 'args' => ['script', '--verbose', '-f', 'value'],
+ 'expected' => 'run-script-value',
+ ],
+ 'special characters sanitized' => [
+ 'command' => 'test/command:name',
+ 'args' => ['arg@with#special', 'arg with spaces'],
+ 'expected' => 'test-command-name-arg-with-special-arg-with-spaces',
+ ],
+ 'multiple consecutive hyphens collapsed' => [
+ 'command' => 'test---command',
+ 'args' => ['arg***value'],
+ 'expected' => 'test-command-arg-value',
+ ],
+ 'empty result fallback' => [
+ 'command' => '---',
+ 'args' => ['--option', '-f'],
+ 'expected' => 'runner',
+ ],
+ ];
+ }
+
+}
diff --git a/.vortex/installer/tests/Unit/Runner/AbstractRunnerTest.php b/.vortex/installer/tests/Unit/Runner/AbstractRunnerTest.php
new file mode 100644
index 000000000..86168bbb4
--- /dev/null
+++ b/.vortex/installer/tests/Unit/Runner/AbstractRunnerTest.php
@@ -0,0 +1,743 @@
+getLogger();
+ $this->assertInstanceOf(FileLogger::class, $logger1);
+
+ $logger2 = $runner->getLogger();
+ $this->assertSame($logger1, $logger2, 'getLogger() should return the same instance on subsequent calls');
+ }
+
+ /**
+ * Test getCwd returns current directory by default.
+ */
+ public function testGetCwdReturnsCurrentDirectory(): void {
+ $runner = new ConcreteRunner();
+
+ $cwd = $runner->getCwd();
+ $this->assertEquals(getcwd(), $cwd);
+ }
+
+ /**
+ * Test setCwd sets custom directory.
+ */
+ public function testSetCwdSetsCustomDirectory(): void {
+ $runner = new ConcreteRunner();
+
+ $result = $runner->setCwd('/custom/path');
+ $this->assertEquals('/custom/path', $runner->getCwd());
+ $this->assertInstanceOf(AbstractRunner::class, $result, 'setCwd() should return self for method chaining');
+ }
+
+ /**
+ * Test setCwd updates logger directory.
+ */
+ public function testSetCwdUpdatesLoggerDirectory(): void {
+ $runner = new ConcreteRunner();
+ $logger = $runner->getLogger();
+
+ $runner->setCwd(self::$tmp);
+
+ $this->assertEquals(self::$tmp, $logger->getDir());
+ }
+
+ /**
+ * Test enableLog calls logger's enable.
+ */
+ public function testEnableLog(): void {
+ $runner = new ConcreteRunner();
+ $logger = $runner->getLogger();
+
+ $logger->disable();
+ $this->assertFalse($logger->isEnabled());
+
+ $result = $runner->enableLog();
+ $this->assertTrue($logger->isEnabled());
+ $this->assertInstanceOf(AbstractRunner::class, $result, 'enableLog() should return self for method chaining');
+ }
+
+ /**
+ * Test disableLog calls logger's disable.
+ */
+ public function testDisableLog(): void {
+ $runner = new ConcreteRunner();
+ $logger = $runner->getLogger();
+
+ $this->assertTrue($logger->isEnabled());
+
+ $result = $runner->disableLog();
+ $this->assertFalse($logger->isEnabled());
+ $this->assertInstanceOf(AbstractRunner::class, $result, 'disableLog() should return self for method chaining');
+ }
+
+ /**
+ * Test enableStreaming sets internal flag.
+ */
+ public function testEnableStreaming(): void {
+ $runner = new ConcreteRunner();
+
+ // Streaming is enabled by default.
+ $this->assertTrue($runner->shouldStream());
+
+ $runner->disableStreaming();
+ $this->assertFalse($runner->shouldStream());
+
+ $result = $runner->enableStreaming();
+ $this->assertTrue($runner->shouldStream());
+ $this->assertInstanceOf(AbstractRunner::class, $result, 'enableStreaming() should return self for method chaining');
+ }
+
+ /**
+ * Test disableStreaming sets internal flag.
+ */
+ public function testDisableStreaming(): void {
+ $runner = new ConcreteRunner();
+
+ $this->assertTrue($runner->shouldStream());
+
+ $result = $runner->disableStreaming();
+ $this->assertFalse($runner->shouldStream());
+ $this->assertInstanceOf(AbstractRunner::class, $result, 'disableStreaming() should return self for method chaining');
+ }
+
+ /**
+ * Test getCommand returns NULL initially.
+ */
+ public function testGetCommandInitiallyNull(): void {
+ $runner = new ConcreteRunner();
+
+ $this->assertNull($runner->getCommand());
+ }
+
+ /**
+ * Test getExitCode returns 0 initially.
+ */
+ public function testGetExitCodeInitiallyZero(): void {
+ $runner = new ConcreteRunner();
+
+ $this->assertEquals(0, $runner->getExitCode());
+ }
+
+ /**
+ * Test getOutput returns empty string initially.
+ */
+ public function testGetOutputInitiallyEmpty(): void {
+ $runner = new ConcreteRunner();
+
+ $this->assertEquals('', $runner->getOutput());
+ }
+
+ /**
+ * Test parseCommand with various formats.
+ */
+ #[DataProvider('dataProviderParseCommand')]
+ public function testParseCommand(string $command, array $expected, ?string $expected_exception, ?string $expected_message): void {
+ if ($expected_exception !== NULL) {
+ /** @var class-string<\Throwable> $expected_exception */
+ $this->expectException($expected_exception);
+ $this->expectExceptionMessage($expected_message ?? '');
+ }
+
+ $runner = new ConcreteRunner();
+ $result = $runner->parseCommandPublic($command);
+
+ if ($expected_exception === NULL) {
+ $this->assertEquals($expected, $result);
+ }
+ }
+
+ /**
+ * Data provider for parseCommand.
+ */
+ public static function dataProviderParseCommand(): array {
+ return [
+ 'simple command' => [
+ 'command' => 'echo',
+ 'expected' => ['echo'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with arguments' => [
+ 'command' => 'echo hello world',
+ 'expected' => ['echo', 'hello', 'world'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with single-quoted argument' => [
+ 'command' => "echo 'hello world'",
+ 'expected' => ['echo', 'hello world'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with double-quoted argument' => [
+ 'command' => 'echo "hello world"',
+ 'expected' => ['echo', 'hello world'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with escaped character' => [
+ 'command' => 'echo hello\\ world',
+ 'expected' => ['echo', 'hello world'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with escaped quote inside single quotes' => [
+ 'command' => "echo 'It\\'s working'",
+ 'expected' => ['echo', "It's working"],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with mixed quotes' => [
+ 'command' => 'echo "hello" \'world\'',
+ 'expected' => ['echo', 'hello', 'world'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with end-of-options marker' => [
+ 'command' => 'echo -- --not-an-option',
+ 'expected' => ['echo', '--', '--not-an-option'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with single short option' => [
+ 'command' => 'ls -l',
+ 'expected' => ['ls', '-l'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with multiple short options' => [
+ 'command' => 'ls -la',
+ 'expected' => ['ls', '-la'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with separate short options' => [
+ 'command' => 'ls -l -a -h',
+ 'expected' => ['ls', '-l', '-a', '-h'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with long option' => [
+ 'command' => 'ls --all',
+ 'expected' => ['ls', '--all'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with long option with equals value' => [
+ 'command' => 'composer require --dev=phpunit',
+ 'expected' => ['composer', 'require', '--dev=phpunit'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with long option with space-separated value' => [
+ 'command' => 'git commit -m "commit message"',
+ 'expected' => ['git', 'commit', '-m', 'commit message'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with option value with equals' => [
+ 'command' => 'command --option=value',
+ 'expected' => ['command', '--option=value'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with option value with spaces' => [
+ 'command' => 'command --option="value with spaces"',
+ 'expected' => ['command', '--option=value with spaces'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with mixed options and arguments' => [
+ 'command' => 'ls -la /path/to/dir',
+ 'expected' => ['ls', '-la', '/path/to/dir'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with options and quoted arguments' => [
+ 'command' => 'grep -r "search term" /path/to/dir',
+ 'expected' => ['grep', '-r', 'search term', '/path/to/dir'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'complex command with multiple options and arguments' => [
+ 'command' => 'docker run -it --rm --name=mycontainer -v /host:/container ubuntu:latest bash',
+ 'expected' => ['docker', 'run', '-it', '--rm', '--name=mycontainer', '-v', '/host:/container', 'ubuntu:latest', 'bash'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with option containing special characters' => [
+ 'command' => 'curl -H "Authorization: Bearer token123"',
+ 'expected' => ['curl', '-H', 'Authorization: Bearer token123'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with multiple long options with values' => [
+ 'command' => 'command --option1=value1 --option2=value2 --flag',
+ 'expected' => ['command', '--option1=value1', '--option2=value2', '--flag'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with mixed short and long options' => [
+ 'command' => 'command -a -b --long-option --another=value arg1 arg2',
+ 'expected' => ['command', '-a', '-b', '--long-option', '--another=value', 'arg1', 'arg2'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with options before and after arguments' => [
+ 'command' => 'find /path -name "*.txt" -type f',
+ 'expected' => ['find', '/path', '-name', '*.txt', '-type', 'f'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with option value containing equals sign' => [
+ 'command' => 'command --url="http://example.com?param=value"',
+ 'expected' => ['command', '--url=http://example.com?param=value'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with negative number argument' => [
+ 'command' => 'command -n -42',
+ 'expected' => ['command', '-n', '-42'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with option and empty string value' => [
+ 'command' => 'command --option=""',
+ 'expected' => ['command', '--option='],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'empty command' => [
+ 'command' => '',
+ 'expected' => [],
+ 'expected_exception' => \InvalidArgumentException::class,
+ 'expected_message' => 'Command cannot be empty.',
+ ],
+ 'whitespace only command' => [
+ 'command' => ' ',
+ 'expected' => [],
+ 'expected_exception' => \InvalidArgumentException::class,
+ 'expected_message' => 'Command cannot be empty.',
+ ],
+ 'unclosed single quote' => [
+ 'command' => "echo 'unclosed",
+ 'expected' => [],
+ 'expected_exception' => \InvalidArgumentException::class,
+ 'expected_message' => 'Unclosed quote in command string.',
+ ],
+ 'unclosed double quote' => [
+ 'command' => 'echo "unclosed',
+ 'expected' => [],
+ 'expected_exception' => \InvalidArgumentException::class,
+ 'expected_message' => 'Unclosed quote in command string.',
+ ],
+ 'trailing escape' => [
+ 'command' => 'echo trailing\\',
+ 'expected' => [],
+ 'expected_exception' => \InvalidArgumentException::class,
+ 'expected_message' => 'Trailing escape character in command string.',
+ ],
+ ];
+ }
+
+ /**
+ * Test reset method.
+ */
+ public function testReset(): void {
+ $runner = new ConcreteRunner();
+
+ $runner->setCommand('test-command');
+ $runner->setOutput('test output');
+ $runner->setExitCode(1);
+
+ $this->assertEquals('test-command', $runner->getCommand());
+ $this->assertEquals('test output', $runner->getOutput());
+ $this->assertEquals(1, $runner->getExitCode());
+
+ $runner->resetPublic();
+
+ $this->assertNull($runner->getCommand());
+ $this->assertEquals('', $runner->getOutput());
+ $this->assertEquals(0, $runner->getExitCode());
+ }
+
+ /**
+ * Test initLogger sets correct directory and opens log.
+ */
+ public function testInitLogger(): void {
+ $runner = new ConcreteRunner();
+ $runner->setCwd(self::$tmp);
+
+ $logger = $runner->initLoggerPublic('test-command', ['arg1', 'arg2']);
+
+ $this->assertInstanceOf(FileLogger::class, $logger);
+ $this->assertEquals(self::$tmp, $logger->getDir());
+
+ $path = $logger->getPath();
+ $this->assertNotNull($path);
+ $this->assertStringContainsString('test-command', $path);
+ $this->assertStringContainsString('arg1', $path);
+ $this->assertStringContainsString('arg2', $path);
+
+ $logger->close();
+ }
+
+ /**
+ * Test resolveOutput with NULL uses default.
+ */
+ public function testResolveOutputWithNull(): void {
+ $runner = new ConcreteRunner();
+
+ // Initialize Tui with a mock output first.
+ $mock_output = $this->createMock(OutputInterface::class);
+ Tui::init($mock_output);
+
+ $output = $runner->resolveOutputPublic(NULL);
+
+ $this->assertInstanceOf(OutputInterface::class, $output);
+ $this->assertSame($mock_output, $output);
+ }
+
+ /**
+ * Test resolveOutput with provided output.
+ */
+ public function testResolveOutputWithProvided(): void {
+ $runner = new ConcreteRunner();
+ $mock_output = $this->createMock(OutputInterface::class);
+
+ $output = $runner->resolveOutputPublic($mock_output);
+
+ $this->assertSame($mock_output, $output);
+ }
+
+ /**
+ * Test getOutput with as_array parameter.
+ */
+ #[DataProvider('dataProviderGetOutputVariations')]
+ public function testGetOutputVariations(string $output, bool $as_array, ?int $lines, string | array $expected): void {
+ $runner = new ConcreteRunner();
+ $runner->setOutput($output);
+
+ $result = $runner->getOutput($as_array, $lines);
+
+ $this->assertEquals($expected, $result);
+ }
+
+ /**
+ * Data provider for getOutput variations.
+ */
+ public static function dataProviderGetOutputVariations(): array {
+ return [
+ 'string output, as_array=false, no limit' => [
+ 'output' => "Line 1\nLine 2\nLine 3",
+ 'as_array' => FALSE,
+ 'lines' => NULL,
+ 'expected' => "Line 1\nLine 2\nLine 3",
+ ],
+ 'string output, as_array=true, no limit' => [
+ 'output' => "Line 1\nLine 2\nLine 3",
+ 'as_array' => TRUE,
+ 'lines' => NULL,
+ 'expected' => ['Line 1', 'Line 2', 'Line 3'],
+ ],
+ 'string output, as_array=false, limit=2' => [
+ 'output' => "Line 1\nLine 2\nLine 3",
+ 'as_array' => FALSE,
+ 'lines' => 2,
+ 'expected' => "Line 1\nLine 2",
+ ],
+ 'string output, as_array=true, limit=2' => [
+ 'output' => "Line 1\nLine 2\nLine 3",
+ 'as_array' => TRUE,
+ 'lines' => 2,
+ 'expected' => ['Line 1', 'Line 2'],
+ ],
+ 'empty output, as_array=false' => [
+ 'output' => '',
+ 'as_array' => FALSE,
+ 'lines' => NULL,
+ 'expected' => '',
+ ],
+ 'empty output, as_array=true' => [
+ 'output' => '',
+ 'as_array' => TRUE,
+ 'lines' => NULL,
+ 'expected' => [''],
+ ],
+ ];
+ }
+
+ /**
+ * Test buildCommandString with various arguments.
+ */
+ #[DataProvider('dataProviderBuildCommandString')]
+ public function testBuildCommandString(string $command, array $args, array $opts, string $expected): void {
+ $runner = new ConcreteRunner();
+
+ $result = $runner->buildCommandStringPublic($command, $args, $opts);
+
+ $this->assertEquals($expected, $result);
+ }
+
+ /**
+ * Data provider for buildCommandString.
+ */
+ public static function dataProviderBuildCommandString(): array {
+ return [
+ 'command only' => [
+ 'command' => 'echo',
+ 'args' => [],
+ 'opts' => [],
+ 'expected' => 'echo',
+ ],
+ 'command with positional args' => [
+ 'command' => 'echo',
+ 'args' => ['hello', 'world'],
+ 'opts' => [],
+ 'expected' => 'echo hello world',
+ ],
+ 'command with named options' => [
+ 'command' => 'echo',
+ 'args' => [],
+ 'opts' => ['--verbose' => TRUE, '--format' => 'json'],
+ 'expected' => 'echo --verbose --format=json',
+ ],
+ 'command with mixed args and options' => [
+ 'command' => 'echo',
+ 'args' => ['hello'],
+ 'opts' => ['--verbose' => TRUE],
+ 'expected' => 'echo hello --verbose',
+ ],
+ 'argument with spaces requires quoting' => [
+ 'command' => 'echo',
+ 'args' => ['hello world'],
+ 'opts' => [],
+ 'expected' => "echo 'hello world'",
+ ],
+ 'empty string argument' => [
+ 'command' => 'echo',
+ 'args' => [''],
+ 'opts' => [],
+ 'expected' => "echo ''",
+ ],
+ ];
+ }
+
+ /**
+ * Test quoteArgument method.
+ */
+ #[DataProvider('dataProviderQuoteArgument')]
+ public function testQuoteArgument(string $argument, string $expected): void {
+ $runner = new ConcreteRunner();
+
+ $result = $runner->quoteArgumentPublic($argument);
+
+ $this->assertEquals($expected, $result);
+ }
+
+ /**
+ * Data provider for quoteArgument.
+ */
+ public static function dataProviderQuoteArgument(): array {
+ return [
+ 'simple string (no quoting)' => [
+ 'argument' => 'hello',
+ 'expected' => 'hello',
+ ],
+ 'string with spaces' => [
+ 'argument' => 'hello world',
+ 'expected' => "'hello world'",
+ ],
+ 'string with single quote' => [
+ 'argument' => "It's working",
+ 'expected' => "'It'\\''s working'",
+ ],
+ 'string with double quote' => [
+ 'argument' => 'Say "hello"',
+ 'expected' => "'Say \"hello\"'",
+ ],
+ 'string with shell special chars' => [
+ 'argument' => 'test$variable',
+ 'expected' => "'test\$variable'",
+ ],
+ 'empty string' => [
+ 'argument' => '',
+ 'expected' => "''",
+ ],
+ 'string with backslash' => [
+ 'argument' => 'path\\to\\file',
+ 'expected' => "'path\\to\\file'",
+ ],
+ ];
+ }
+
+ /**
+ * Test formatArgs method.
+ */
+ #[DataProvider('dataProviderFormatArgs')]
+ public function testFormatArgs(array $args, array $expected): void {
+ $runner = new ConcreteRunner();
+
+ $result = $runner->formatArgsPublic($args);
+
+ $this->assertEquals($expected, $result);
+ }
+
+ /**
+ * Data provider for formatArgs.
+ */
+ public static function dataProviderFormatArgs(): array {
+ return [
+ 'positional args' => [
+ 'args' => ['arg1', 'arg2'],
+ 'expected' => ['arg1', 'arg2'],
+ ],
+ 'named args with string values' => [
+ 'args' => ['--option' => 'value', '--flag' => 'enabled'],
+ 'expected' => ['--option=value', '--flag=enabled'],
+ ],
+ 'named args with bool TRUE' => [
+ 'args' => ['--verbose' => TRUE],
+ 'expected' => ['--verbose'],
+ ],
+ 'named args with bool FALSE (excluded)' => [
+ 'args' => ['--verbose' => FALSE],
+ 'expected' => [],
+ ],
+ 'positional args with bool TRUE' => [
+ 'args' => [TRUE],
+ 'expected' => ['1'],
+ ],
+ 'positional args with bool FALSE (excluded)' => [
+ 'args' => [FALSE],
+ 'expected' => [],
+ ],
+ 'mixed positional and named' => [
+ 'args' => ['pos1', '--opt' => 'val', 'pos2'],
+ 'expected' => ['pos1', '--opt=val', 'pos2'],
+ ],
+ ];
+ }
+
+}
+
+/**
+ * Concrete runner implementation for testing AbstractRunner.
+ */
+class ConcreteRunner extends AbstractRunner {
+
+ /**
+ * {@inheritdoc}
+ */
+ public function run(string $command, array $args = [], array $inputs = [], array $env = [], ?OutputInterface $output = NULL): static {
+ // Simple implementation for testing.
+ $this->command = $command;
+ return $this;
+ }
+
+ /**
+ * Public wrapper for parseCommand.
+ */
+ public function parseCommandPublic(string $command): array {
+ return $this->parseCommand($command);
+ }
+
+ /**
+ * Public wrapper for buildCommandString.
+ */
+ public function buildCommandStringPublic(string $command, array $args = [], array $opts = []): string {
+ return $this->buildCommandString($command, $args, $opts);
+ }
+
+ /**
+ * Public wrapper for quoteArgument.
+ */
+ public function quoteArgumentPublic(string $argument): string {
+ return $this->quoteArgument($argument);
+ }
+
+ /**
+ * Public wrapper for formatArgs.
+ */
+ public function formatArgsPublic(array $args): array {
+ return $this->formatArgs($args);
+ }
+
+ /**
+ * Public wrapper for reset.
+ */
+ public function resetPublic(): void {
+ $this->reset();
+ }
+
+ /**
+ * Public setter for command (for testing).
+ */
+ public function setCommand(string $command): void {
+ $this->command = $command;
+ }
+
+ /**
+ * Public setter for output (for testing).
+ */
+ public function setOutput(string $output): void {
+ $this->output = $output;
+ }
+
+ /**
+ * Public setter for exitCode (for testing).
+ */
+ public function setExitCode(int $exitCode): void {
+ if ($exitCode < 0 || $exitCode > 255) {
+ throw new \RuntimeException('Exit code is out of valid range (0-255).');
+ }
+
+ $this->exitCode = $exitCode;
+ }
+
+ /**
+ * Public getter for shouldStream (for testing).
+ */
+ public function shouldStream(): bool {
+ return $this->shouldStream;
+ }
+
+ /**
+ * Public wrapper for initLogger.
+ */
+ public function initLoggerPublic(string $command, array $args = []): FileLoggerInterface {
+ return $this->initLogger($command, $args);
+ }
+
+ /**
+ * Public wrapper for resolveOutput.
+ */
+ public function resolveOutputPublic(?OutputInterface $output): OutputInterface {
+ return $this->resolveOutput($output);
+ }
+
+}
diff --git a/.vortex/installer/tests/Unit/Runner/CommandRunnerTest.php b/.vortex/installer/tests/Unit/Runner/CommandRunnerTest.php
new file mode 100644
index 000000000..d6dc0def7
--- /dev/null
+++ b/.vortex/installer/tests/Unit/Runner/CommandRunnerTest.php
@@ -0,0 +1,242 @@
+assertInstanceOf(CommandRunner::class, $runner);
+ }
+
+ /**
+ * Test run with valid command.
+ */
+ public function testRunWithValidCommand(): void {
+ $application = new Application();
+ $command = new TestCommand('test:command');
+ $application->add($command);
+
+ $runner = new CommandRunner($application);
+ $runner->setCwd(self::$tmp);
+
+ $output = new BufferedOutput();
+ Tui::init($output);
+
+ $result = $runner->run('test:command');
+
+ $this->assertInstanceOf(CommandRunner::class, $result);
+ $this->assertEquals(0, $runner->getExitCode());
+ $runner_output = $runner->getOutput();
+ $this->assertStringContainsString('Test output', is_string($runner_output) ? $runner_output : implode(PHP_EOL, $runner_output));
+ }
+
+ /**
+ * Test run with streaming enabled/disabled.
+ */
+ #[DataProvider('dataProviderRunWithStreaming')]
+ public function testRunWithStreaming(bool $streaming_enabled, bool $should_have_output): void {
+ $application = new Application();
+ $command = new TestCommand('test:command');
+ $application->add($command);
+
+ $runner = new CommandRunner($application);
+ $runner->setCwd(self::$tmp);
+
+ if (!$streaming_enabled) {
+ $runner->disableStreaming();
+ }
+
+ $output = new BufferedOutput();
+ Tui::init($output);
+
+ $runner->run('test:command', [], [], [], $output);
+
+ $stream_content = $output->fetch();
+
+ if ($should_have_output) {
+ $this->assertStringContainsString('Test output', $stream_content);
+ }
+ else {
+ $this->assertStringNotContainsString('Test output', $stream_content);
+ }
+
+ // Output should always be captured in runner.
+ $output = $runner->getOutput();
+ $this->assertStringContainsString('Test output', is_string($output) ? $output : implode(PHP_EOL, $output));
+ }
+
+ /**
+ * Test createCompositeOutput method using reflection.
+ */
+ public function testCreateCompositeOutput(): void {
+ $application = new Application();
+ $runner = new CommandRunner($application);
+ $runner->setCwd(self::$tmp);
+
+ $output = new BufferedOutput();
+ $logger = new FileLogger();
+ $logger->setDir(self::$tmp);
+ $logger->open('test');
+
+ // Use reflection to access protected method.
+ $reflection = new \ReflectionClass($runner);
+ $method = $reflection->getMethod('createCompositeOutput');
+
+ [$composite_output, $buffered_output] = $method->invoke($runner, $output, $logger);
+
+ $this->assertInstanceOf(OutputInterface::class, $composite_output);
+ $this->assertInstanceOf(BufferedOutput::class, $buffered_output);
+
+ // Test composite output behavior.
+ $composite_output->write('Test message');
+ $this->assertStringContainsString('Test message', $buffered_output->fetch());
+
+ $composite_output->writeln('Another line');
+ $this->assertStringContainsString('Another line', $buffered_output->fetch());
+
+ $logger->close();
+ }
+
+ /**
+ * Test composite output with iterable messages.
+ */
+ public function testCompositeOutputWithIterableMessages(): void {
+ $application = new Application();
+ $runner = new CommandRunner($application);
+ $runner->setCwd(self::$tmp);
+
+ $output = new BufferedOutput();
+ $logger = new FileLogger();
+ $logger->setDir(self::$tmp);
+ $logger->open('test');
+
+ // Use reflection to access protected method.
+ $reflection = new \ReflectionClass($runner);
+ $method = $reflection->getMethod('createCompositeOutput');
+
+ [$composite_output, $buffered_output] = $method->invoke($runner, $output, $logger);
+
+ // Test with iterable messages.
+ $composite_output->write(['Line 1', 'Line 2']);
+ $content = $buffered_output->fetch();
+ $this->assertStringContainsString('Line 1', $content);
+ $this->assertStringContainsString('Line 2', $content);
+
+ $composite_output->writeln(['Line 3', 'Line 4']);
+ $content = $buffered_output->fetch();
+ $this->assertStringContainsString('Line 3', $content);
+ $this->assertStringContainsString('Line 4', $content);
+
+ $logger->close();
+ }
+
+ /**
+ * Test run with options.
+ */
+ public function testRunWithOptions(): void {
+ $application = new Application();
+ $command = new TestCommand('test:command');
+ $application->add($command);
+
+ $runner = new CommandRunner($application);
+ $runner->setCwd(self::$tmp);
+
+ $output = new BufferedOutput();
+ Tui::init($output);
+
+ // Test without options since test command doesn't define any.
+ $runner->run('test:command', []);
+
+ $this->assertEquals(0, $runner->getExitCode());
+ }
+
+ /**
+ * Test run captures exit code.
+ */
+ public function testRunCapturesExitCode(): void {
+ $application = new Application();
+ $command = new TestCommandWithExitCode('test:error');
+ $application->add($command);
+
+ $runner = new CommandRunner($application);
+ $runner->setCwd(self::$tmp);
+
+ $output = new BufferedOutput();
+ Tui::init($output);
+
+ $runner->run('test:error');
+
+ $this->assertEquals(1, $runner->getExitCode());
+ }
+
+ /**
+ * Data provider for streaming modes.
+ */
+ public static function dataProviderRunWithStreaming(): array {
+ return [
+ 'streaming enabled' => [
+ 'streaming_enabled' => TRUE,
+ 'should_have_output' => TRUE,
+ ],
+ 'streaming disabled' => [
+ 'streaming_enabled' => FALSE,
+ 'should_have_output' => FALSE,
+ ],
+ ];
+ }
+
+}
+
+/**
+ * Test command for testing CommandRunner.
+ */
+class TestCommand extends Command {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $output->writeln('Test output');
+ return 0;
+ }
+
+}
+
+/**
+ * Test command that returns non-zero exit code.
+ */
+class TestCommandWithExitCode extends Command {
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $output->writeln('Error output');
+ return 1;
+ }
+
+}
diff --git a/.vortex/installer/tests/Unit/Runner/ProcessRunnerTest.php b/.vortex/installer/tests/Unit/Runner/ProcessRunnerTest.php
new file mode 100644
index 000000000..64e501ffe
--- /dev/null
+++ b/.vortex/installer/tests/Unit/Runner/ProcessRunnerTest.php
@@ -0,0 +1,413 @@
+ $expected_exception */
+ $this->expectException($expected_exception);
+ $this->expectExceptionMessage($expected_message ?? '');
+ }
+
+ $runner = new ProcessRunner();
+ $runner->setCwd(self::$tmp);
+
+ // Initialize Tui for output.
+ $output = new BufferedOutput();
+ Tui::init($output);
+
+ $result = $runner->run($command, $args);
+
+ if ($expected_exception === NULL) {
+ $this->assertInstanceOf(ProcessRunner::class, $result);
+ $this->assertEquals($expected_exit_code, $runner->getExitCode());
+ $output = $runner->getOutput();
+ $this->assertMatchesRegularExpression($expected_output_pattern, is_string($output) ? $output : implode(PHP_EOL, $output));
+ $this->assertNotNull($runner->getCommand());
+ }
+ }
+
+ /**
+ * Test run with output streaming.
+ */
+ #[DataProvider('dataProviderRunWithStreaming')]
+ public function testRunWithStreaming(bool $streaming_enabled, bool $should_have_output_in_stream): void {
+ $runner = new ProcessRunner();
+ $runner->setCwd(self::$tmp);
+
+ if (!$streaming_enabled) {
+ $runner->disableStreaming();
+ }
+
+ $output = new BufferedOutput();
+ Tui::init($output);
+
+ $runner->run('echo "test output"', []);
+
+ $output_content = $output->fetch();
+
+ if ($should_have_output_in_stream) {
+ $this->assertStringContainsString('test output', $output_content);
+ }
+ else {
+ $this->assertStringNotContainsString('test output', $output_content);
+ }
+
+ // Output should always be captured in runner.
+ $runner_output = $runner->getOutput();
+ $this->assertStringContainsString('test output', is_string($runner_output) ? $runner_output : implode(PHP_EOL, $runner_output));
+ }
+
+ /**
+ * Test resolveCommand with various command types.
+ */
+ #[DataProvider('dataProviderResolveCommand')]
+ public function testResolveCommand(string $command, bool $expect_success, ?string $expected_exception, ?string $expected_message): void {
+ if ($expected_exception !== NULL) {
+ /** @var class-string<\Throwable> $expected_exception */
+ $this->expectException($expected_exception);
+ $this->expectExceptionMessage($expected_message ?? '');
+ }
+
+ $runner = new TestableProcessRunner();
+ $runner->setCwd(self::$tmp);
+
+ [$resolved, $parsed] = $runner->resolveCommandPublic($command);
+
+ if ($expect_success) {
+ $this->assertNotEmpty($resolved);
+ $this->assertIsArray($parsed);
+ }
+ }
+
+ /**
+ * Test prepareArguments method.
+ */
+ #[DataProvider('dataProviderPrepareArguments')]
+ public function testPrepareArguments(array $parsed_args, array $additional_args, array $expected, ?string $expected_exception, ?string $expected_message): void {
+ if ($expected_exception !== NULL) {
+ /** @var class-string<\Throwable> $expected_exception */
+ $this->expectException($expected_exception);
+ $this->expectExceptionMessage($expected_message ?? '');
+ }
+
+ $runner = new TestableProcessRunner();
+
+ $result = $runner->prepareArgumentsPublic($parsed_args, $additional_args);
+
+ if ($expected_exception === NULL) {
+ $this->assertEquals($expected, $result);
+ }
+ }
+
+ /**
+ * Test validateEnvironmentVars method.
+ */
+ #[DataProvider('dataProviderValidateEnvironmentVars')]
+ public function testValidateEnvironmentVars(array $env, ?string $expected_exception, ?string $expected_message): void {
+ if ($expected_exception !== NULL) {
+ /** @var class-string<\Throwable> $expected_exception */
+ $this->expectException($expected_exception);
+ $this->expectExceptionMessage($expected_message ?? '');
+ }
+
+ $runner = new TestableProcessRunner();
+
+ $runner->validateEnvironmentVarsPublic($env);
+
+ if ($expected_exception === NULL) {
+ $this->addToAssertionCount(1);
+ }
+ }
+
+ /**
+ * Test run with environment variables.
+ */
+ public function testRunWithEnvironmentVariables(): void {
+ $runner = new ProcessRunner();
+ $runner->setCwd(self::$tmp);
+
+ $output = new BufferedOutput();
+ Tui::init($output);
+
+ // Use printenv command which is more reliable for testing env vars.
+ // On Windows, we skip this test as printenv may not be available.
+ if (PHP_OS_FAMILY === 'Windows') {
+ $this->markTestSkipped('Environment variable test not compatible with Windows.');
+ }
+
+ $runner->run('printenv TEST_VAR', [], [], ['TEST_VAR' => 'test_value']);
+
+ $output = $runner->getOutput();
+ $this->assertStringContainsString('test_value', is_string($output) ? $output : implode(PHP_EOL, $output));
+ }
+
+ /**
+ * Test run with working directory.
+ */
+ public function testRunWithWorkingDirectory(): void {
+ $runner = new ProcessRunner();
+ $test_dir = self::$tmp . '/test_subdir';
+ File::mkdir($test_dir);
+
+ $runner->setCwd($test_dir);
+
+ $output = new BufferedOutput();
+ Tui::init($output);
+
+ $runner->run('pwd', []);
+
+ $output = $runner->getOutput();
+ $this->assertStringContainsString($test_dir, is_string($output) ? $output : implode(PHP_EOL, $output));
+ }
+
+ /**
+ * Test resolveCommand with relative path.
+ */
+ public function testResolveCommandWithRelativePath(): void {
+ $runner = new TestableProcessRunner();
+ $test_dir = self::$tmp . '/test_scripts';
+ File::mkdir($test_dir);
+
+ // Create an executable script.
+ $script_path = $test_dir . '/test_script.sh';
+ File::dump($script_path, "#!/bin/sh\necho 'test'\n");
+ chmod($script_path, 0755);
+
+ $runner->setCwd(self::$tmp);
+
+ [$resolved, $parsed] = $runner->resolveCommandPublic('test_scripts/test_script.sh');
+
+ $this->assertEquals($test_dir . '/test_script.sh', $resolved);
+ $this->assertEmpty($parsed);
+ }
+
+ /**
+ * Test prepareArguments with object that can't be cast to scalar.
+ */
+ public function testPrepareArgumentsWithNonScalarAfterFormatting(): void {
+ $runner = new TestableProcessRunner();
+
+ // Create a test object that formatArgs will add to the array,
+ // but which will fail the scalar check.
+ // However, formatArgs will cast it to string first, so this is hard
+ // to trigger.
+ // Let's test with an actual non-scalar after formatArgs processes it.
+ // Since formatArgs always produces strings, line 126 might be unreachable
+ // through normal usage. Let's document this.
+ // For now, just test that normal args work.
+ $result = $runner->prepareArgumentsPublic(['test'], ['arg1', 'arg2']);
+
+ $this->assertEquals(['test', 'arg1', 'arg2'], $result);
+ }
+
+ /**
+ * Data provider for run command tests.
+ */
+ public static function dataProviderRun(): array {
+ return [
+ 'simple echo command' => [
+ 'command' => 'echo',
+ 'args' => ['hello', 'world'],
+ 'expected_output_pattern' => '/hello world/',
+ 'expected_exit_code' => 0,
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with single argument' => [
+ 'command' => 'echo "test message"',
+ 'args' => [],
+ 'expected_output_pattern' => '/test message/',
+ 'expected_exit_code' => 0,
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command not found' => [
+ 'command' => 'nonexistent_command_12345',
+ 'args' => [],
+ 'expected_output_pattern' => '//',
+ 'expected_exit_code' => 0,
+ 'expected_exception' => \InvalidArgumentException::class,
+ 'expected_message' => 'Command not found',
+ ],
+ 'command with invalid characters' => [
+ 'command' => '$invalid-cmd',
+ 'args' => [],
+ 'expected_output_pattern' => '//',
+ 'expected_exit_code' => 0,
+ 'expected_exception' => \InvalidArgumentException::class,
+ 'expected_message' => 'Invalid command',
+ ],
+ 'command utility is not allowed' => [
+ 'command' => 'command',
+ 'args' => ['-v', 'ls'],
+ 'expected_output_pattern' => '//',
+ 'expected_exit_code' => 0,
+ 'expected_exception' => \InvalidArgumentException::class,
+ 'expected_message' => 'Using the "command" utility is not allowed. Use Symfony\Component\Process\ExecutableFinder',
+ ],
+ ];
+ }
+
+ /**
+ * Data provider for streaming modes.
+ */
+ public static function dataProviderRunWithStreaming(): array {
+ return [
+ 'streaming enabled' => [
+ 'streaming_enabled' => TRUE,
+ 'should_have_output_in_stream' => TRUE,
+ ],
+ 'streaming disabled' => [
+ 'streaming_enabled' => FALSE,
+ 'should_have_output_in_stream' => FALSE,
+ ],
+ ];
+ }
+
+ /**
+ * Data provider for resolveCommand tests.
+ */
+ public static function dataProviderResolveCommand(): array {
+ return [
+ 'simple command (echo)' => [
+ 'command' => 'echo',
+ 'expect_success' => TRUE,
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command with arguments' => [
+ 'command' => 'echo hello',
+ 'expect_success' => TRUE,
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'command not in PATH' => [
+ 'command' => 'nonexistent_cmd_xyz',
+ 'expect_success' => FALSE,
+ 'expected_exception' => \InvalidArgumentException::class,
+ 'expected_message' => 'Command not found',
+ ],
+ 'command with invalid characters' => [
+ 'command' => 'echo$test',
+ 'expect_success' => FALSE,
+ 'expected_exception' => \InvalidArgumentException::class,
+ 'expected_message' => 'Invalid command',
+ ],
+ 'command utility is not allowed' => [
+ 'command' => 'command',
+ 'expect_success' => FALSE,
+ 'expected_exception' => \InvalidArgumentException::class,
+ 'expected_message' => 'Using the "command" utility is not allowed. Use Symfony\Component\Process\ExecutableFinder to check if a command exists instead.',
+ ],
+ ];
+ }
+
+ /**
+ * Data provider for prepareArguments tests.
+ */
+ public static function dataProviderPrepareArguments(): array {
+ return [
+ 'merge parsed and additional args' => [
+ 'parsed_args' => ['arg1', 'arg2'],
+ 'additional_args' => ['arg3', 'arg4'],
+ 'expected' => ['arg1', 'arg2', 'arg3', 'arg4'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'convert numeric args to strings' => [
+ 'parsed_args' => ['test'],
+ 'additional_args' => [123, 456],
+ 'expected' => ['test', '123', '456'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'boolean arguments' => [
+ 'parsed_args' => [],
+ 'additional_args' => ['--verbose' => TRUE, '--quiet' => FALSE],
+ 'expected' => ['--verbose'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'non-scalar argument throws exception' => [
+ 'parsed_args' => ['arg1', ['array']],
+ 'additional_args' => [],
+ 'expected' => [],
+ 'expected_exception' => \InvalidArgumentException::class,
+ 'expected_message' => 'Argument at index "1" must be a scalar value, array given.',
+ ],
+ ];
+ }
+
+ /**
+ * Data provider for environment variables tests.
+ */
+ public static function dataProviderValidateEnvironmentVars(): array {
+ return [
+ 'valid scalar env vars' => [
+ 'env' => ['VAR1' => 'value1', 'VAR2' => 'value2'],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'empty env vars' => [
+ 'env' => [],
+ 'expected_exception' => NULL,
+ 'expected_message' => NULL,
+ ],
+ 'non-scalar env var throws exception' => [
+ 'env' => ['VAR1' => ['array']],
+ 'expected_exception' => \InvalidArgumentException::class,
+ 'expected_message' => 'Environment variable "VAR1" must be a scalar value, array given.',
+ ],
+ ];
+ }
+
+}
+
+/**
+ * Testable ProcessRunner that exposes protected methods.
+ */
+class TestableProcessRunner extends ProcessRunner {
+
+ /**
+ * Public wrapper for resolveCommand.
+ */
+ public function resolveCommandPublic(string $command): array {
+ return $this->resolveCommand($command);
+ }
+
+ /**
+ * Public wrapper for prepareArguments.
+ */
+ public function prepareArgumentsPublic(array $parsed_args, array $additional_args): array {
+ return $this->prepareArguments($parsed_args, $additional_args);
+ }
+
+ /**
+ * Public wrapper for validateEnvironmentVars.
+ */
+ public function validateEnvironmentVarsPublic(array $env): void {
+ $this->validateEnvironmentVars($env);
+ }
+
+}
diff --git a/.vortex/installer/tests/Unit/Task/TaskOutputTest.php b/.vortex/installer/tests/Unit/Task/TaskOutputTest.php
new file mode 100644
index 000000000..04b70386d
--- /dev/null
+++ b/.vortex/installer/tests/Unit/Task/TaskOutputTest.php
@@ -0,0 +1,128 @@
+assertInstanceOf(TaskOutput::class, $output);
+ }
+
+ /**
+ * Test write method dims single message.
+ */
+ public function testWriteSingleMessage(): void {
+ $wrapped = new BufferedOutput();
+ $output = new TaskOutput($wrapped);
+
+ $output->write('Test message');
+
+ $content = $wrapped->fetch();
+ // The message should be dimmed (wrapped with ANSI codes).
+ $this->assertStringContainsString('Test message', $content);
+ }
+
+ /**
+ * Test write method dims iterable messages.
+ */
+ public function testWriteIterableMessages(): void {
+ $wrapped = new BufferedOutput();
+ $output = new TaskOutput($wrapped);
+
+ $output->write(['Line 1', 'Line 2']);
+
+ $content = $wrapped->fetch();
+ $this->assertStringContainsString('Line 1', $content);
+ $this->assertStringContainsString('Line 2', $content);
+ }
+
+ /**
+ * Test writeln method dims single message.
+ */
+ public function testWritelnSingleMessage(): void {
+ $wrapped = new BufferedOutput();
+ $output = new TaskOutput($wrapped);
+
+ $output->writeln('Test message');
+
+ $content = $wrapped->fetch();
+ $this->assertStringContainsString('Test message', $content);
+ }
+
+ /**
+ * Test writeln method dims iterable messages.
+ */
+ public function testWritelnIterableMessages(): void {
+ $wrapped = new BufferedOutput();
+ $output = new TaskOutput($wrapped);
+
+ $output->writeln(['Line 1', 'Line 2']);
+
+ $content = $wrapped->fetch();
+ $this->assertStringContainsString('Line 1', $content);
+ $this->assertStringContainsString('Line 2', $content);
+ }
+
+ /**
+ * Test verbosity delegation.
+ */
+ public function testVerbosityDelegation(): void {
+ $wrapped = new BufferedOutput();
+ $output = new TaskOutput($wrapped);
+
+ $output->setVerbosity(OutputInterface::VERBOSITY_DEBUG);
+ $this->assertEquals(OutputInterface::VERBOSITY_DEBUG, $output->getVerbosity());
+
+ $this->assertFalse($output->isQuiet());
+ $this->assertTrue($output->isVerbose());
+ $this->assertTrue($output->isVeryVerbose());
+ $this->assertTrue($output->isDebug());
+ }
+
+ /**
+ * Test decoration delegation.
+ */
+ public function testDecorationDelegation(): void {
+ $wrapped = new BufferedOutput();
+ $output = new TaskOutput($wrapped);
+
+ $output->setDecorated(TRUE);
+ $this->assertTrue($output->isDecorated());
+
+ $output->setDecorated(FALSE);
+ $this->assertFalse($output->isDecorated());
+ }
+
+ /**
+ * Test formatter delegation.
+ */
+ public function testFormatterDelegation(): void {
+ $wrapped = new BufferedOutput();
+ $output = new TaskOutput($wrapped);
+
+ $formatter = new OutputFormatter();
+ $output->setFormatter($formatter);
+
+ $this->assertSame($formatter, $output->getFormatter());
+ }
+
+}
diff --git a/.vortex/installer/tests/Unit/TaskTest.php b/.vortex/installer/tests/Unit/TaskTest.php
index 1f79d7ccb..d225103f4 100644
--- a/.vortex/installer/tests/Unit/TaskTest.php
+++ b/.vortex/installer/tests/Unit/TaskTest.php
@@ -6,7 +6,7 @@
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
-use DrevOps\VortexInstaller\Utils\Task;
+use DrevOps\VortexInstaller\Task\Task;
use DrevOps\VortexInstaller\Utils\Tui;
use Symfony\Component\Console\Output\BufferedOutput;
@@ -131,4 +131,284 @@ public function testActionInvalidArgument(): void {
Task::action('Test label');
}
+ /**
+ * Test streaming mode with OutputInterface usage.
+ *
+ * This tests when the closure uses Tui methods (or any OutputInterface
+ * methods) to write output.
+ */
+ public function testActionStreamingWithOutputInterface(): void {
+ $output = new BufferedOutput();
+ Tui::init($output);
+
+ $result = Task::action(
+ label: 'Processing with output',
+ action: function (): string {
+ // Use Tui methods that write to the output interface.
+ Tui::line('Line 1 from Tui');
+ Tui::line('Line 2 from Tui');
+ return 'done';
+ },
+ streaming: TRUE,
+ );
+
+ $actual = $output->fetch();
+
+ // Verify the action executed successfully.
+ $this->assertEquals('done', $result);
+
+ // Verify the start message is shown.
+ $this->assertStringContainsString('Processing with output', $actual);
+
+ // Verify the output from Tui methods is captured and dimmed.
+ // TaskOutput wraps messages with dim ANSI codes.
+ $this->assertStringContainsString('Line 1 from Tui', $actual);
+ $this->assertStringContainsString('Line 2 from Tui', $actual);
+
+ // Verify success message is shown.
+ $this->assertStringContainsString('✓ OK', $actual);
+ }
+
+ /**
+ * Test streaming mode with echo/print statements.
+ *
+ * This tests when the closure uses echo/print (PHP output buffering
+ * captures these).
+ */
+ public function testActionStreamingWithEchoPrint(): void {
+ $output = new BufferedOutput();
+ Tui::init($output);
+
+ $result = Task::action(
+ label: 'Processing with echo',
+ action: function (): string {
+ // Use echo/print statements.
+ echo "Echo output line 1\n";
+ echo "Echo output line 2\n";
+ print "Print output line 3\n";
+ return 'completed';
+ },
+ streaming: TRUE,
+ );
+
+ $actual = $output->fetch();
+
+ // Verify the action executed successfully.
+ $this->assertEquals('completed', $result);
+
+ // Verify the start message is shown.
+ $this->assertStringContainsString('Processing with echo', $actual);
+
+ // Verify echo/print output is captured via output buffering.
+ $this->assertStringContainsString('Echo output line 1', $actual);
+ $this->assertStringContainsString('Echo output line 2', $actual);
+ $this->assertStringContainsString('Print output line 3', $actual);
+
+ // Verify success message is shown.
+ $this->assertStringContainsString('✓ OK', $actual);
+ }
+
+ /**
+ * Test streaming mode with both OutputInterface and echo/print.
+ *
+ * This tests that both types of output are captured correctly in the same
+ * action.
+ */
+ public function testActionStreamingWithMixedOutput(): void {
+ $output = new BufferedOutput();
+ Tui::init($output);
+
+ $result = Task::action(
+ label: 'Processing mixed output',
+ action: function (): array {
+ // Mix OutputInterface usage and echo statements.
+ Tui::line('From Tui line 1');
+ echo "From echo line 1\n";
+ Tui::line('From Tui line 2');
+ echo "From echo line 2\n";
+ print "From print line 3\n";
+ return ['result1', 'result2'];
+ },
+ streaming: TRUE,
+ );
+
+ $actual = $output->fetch();
+
+ // Verify the action executed successfully.
+ $this->assertEquals(['result1', 'result2'], $result);
+
+ // Verify both types of output are captured.
+ $this->assertStringContainsString('From Tui line 1', $actual);
+ $this->assertStringContainsString('From echo line 1', $actual);
+ $this->assertStringContainsString('From Tui line 2', $actual);
+ $this->assertStringContainsString('From echo line 2', $actual);
+ $this->assertStringContainsString('From print line 3', $actual);
+
+ // Verify success message is shown.
+ $this->assertStringContainsString('✓ OK', $actual);
+ }
+
+ /**
+ * Test streaming mode with failure.
+ */
+ public function testActionStreamingWithFailure(): void {
+ $output = new BufferedOutput();
+ Tui::init($output);
+
+ $result = Task::action(
+ label: 'Processing that fails',
+ action: function (): false {
+ echo "Some output before failure\n";
+ Tui::line('More output');
+ return FALSE;
+ },
+ failure: 'Operation failed',
+ streaming: TRUE,
+ );
+
+ $actual = $output->fetch();
+
+ // Verify the action returned FALSE.
+ $this->assertFalse($result);
+
+ // Verify output is captured even on failure.
+ $this->assertStringContainsString('Some output before failure', $actual);
+ $this->assertStringContainsString('More output', $actual);
+
+ // Verify failure message is shown.
+ $this->assertStringContainsString('Operation failed', $actual);
+ }
+
+ /**
+ * Test streaming mode with custom success message.
+ */
+ public function testActionStreamingWithCustomSuccessMessage(): void {
+ $output = new BufferedOutput();
+ Tui::init($output);
+
+ $result = Task::action(
+ label: 'Processing data',
+ action: function (): int {
+ echo "Processing item 1\n";
+ echo "Processing item 2\n";
+ return 42;
+ },
+ success: fn(mixed $result): string => sprintf('Processed %s items', $result),
+ streaming: TRUE,
+ );
+
+ $actual = $output->fetch();
+
+ // Verify the action executed successfully.
+ $this->assertEquals(42, $result);
+
+ // Verify output is captured.
+ $this->assertStringContainsString('Processing item 1', $actual);
+ $this->assertStringContainsString('Processing item 2', $actual);
+
+ // Verify custom success message with result is shown.
+ $this->assertStringContainsString('Processed 42 items', $actual);
+ }
+
+ /**
+ * Test that output is restored after streaming.
+ */
+ public function testActionStreamingRestoresOutput(): void {
+ $output = new BufferedOutput();
+ Tui::init($output);
+
+ // Store original output.
+ $original_output = Tui::output();
+
+ Task::action(
+ label: 'Testing output restoration',
+ action: function (): string {
+ echo "Some streamed output\n";
+ return 'done';
+ },
+ streaming: TRUE,
+ );
+
+ // Verify output is restored to the original.
+ $restored_output = Tui::output();
+ $this->assertSame($original_output, $restored_output);
+ }
+
+ /**
+ * Test streaming with no output from action.
+ */
+ public function testActionStreamingWithNoOutput(): void {
+ $output = new BufferedOutput();
+ Tui::init($output);
+
+ $result = Task::action(
+ label: 'Silent processing',
+ action: fn(): string => 'completed',
+ streaming: TRUE,
+ );
+
+ $actual = $output->fetch();
+
+ // Verify the action executed successfully.
+ $this->assertEquals('completed', $result);
+
+ // Verify start message and success are shown even with no action output.
+ $this->assertStringContainsString('Silent processing', $actual);
+ $this->assertStringContainsString('✓ OK', $actual);
+ }
+
+ /**
+ * Test streaming with action that directly uses the output parameter.
+ *
+ * This verifies that when the action callback receives and uses the output
+ * parameter directly (not through Tui), the streaming functionality properly
+ * captures all output and doesn't let it spill elsewhere.
+ */
+ public function testActionStreamingWithDirectOutputUsage(): void {
+ $output = new BufferedOutput();
+ Tui::init($output);
+
+ // Track what was captured in the main output.
+ $result = Task::action(
+ label: 'Processing with direct output',
+ action: function (): string {
+ // Get the current output (which should be TaskOutput during streaming).
+ $current_output = Tui::output();
+
+ // Write directly to the output parameter.
+ $current_output->writeln('Direct output line 1');
+ $current_output->writeln('Direct output line 2');
+
+ // Mix with echo.
+ echo "Echo during streaming\n";
+
+ // Write more to output.
+ $current_output->write('Direct output line 3');
+
+ return 'done';
+ },
+ streaming: TRUE,
+ );
+
+ $actual = $output->fetch();
+
+ // Verify the action executed successfully.
+ $this->assertEquals('done', $result);
+
+ // Verify all output was captured and not spilled.
+ $this->assertStringContainsString('Direct output line 1', $actual);
+ $this->assertStringContainsString('Direct output line 2', $actual);
+ $this->assertStringContainsString('Direct output line 3', $actual);
+ $this->assertStringContainsString('Echo during streaming', $actual);
+
+ // Verify the label and success are shown.
+ $this->assertStringContainsString('Processing with direct output', $actual);
+ $this->assertStringContainsString('✓ OK', $actual);
+
+ // The key verification: all output should be in the buffered output,
+ // not leaked anywhere else. We verify this by checking that the
+ // BufferedOutput received everything.
+ $this->assertNotEmpty($actual);
+ }
+
}
diff --git a/.vortex/installer/tests/Unit/TuiTest.php b/.vortex/installer/tests/Unit/TuiTest.php
index ec47495cb..d70173f2c 100644
--- a/.vortex/installer/tests/Unit/TuiTest.php
+++ b/.vortex/installer/tests/Unit/TuiTest.php
@@ -871,4 +871,84 @@ public static function dataProviderNormalizeText(): array {
];
}
+ /**
+ * Test setOutput method.
+ */
+ public function testSetOutput(): void {
+ $output1 = new BufferedOutput();
+ $output2 = new BufferedOutput();
+
+ Tui::init($output1);
+ $this->assertSame($output1, Tui::output());
+
+ Tui::setOutput($output2);
+ $this->assertSame($output2, Tui::output());
+ }
+
+ /**
+ * Test success method.
+ */
+ public function testSuccess(): void {
+ $output = new BufferedOutput();
+ Tui::init($output);
+
+ Tui::success('Operation succeeded');
+
+ $actual = $output->fetch();
+ $this->assertStringContainsString('Operation succeeded', $actual);
+ }
+
+ /**
+ * Test line method.
+ */
+ public function testLine(): void {
+ $output = new BufferedOutput();
+ Tui::init($output);
+
+ Tui::line('Test line');
+
+ $actual = $output->fetch();
+ $this->assertStringContainsString('Test line', $actual);
+ }
+
+ /**
+ * Test line method with custom padding.
+ */
+ public function testLineWithPadding(): void {
+ $output = new BufferedOutput();
+ Tui::init($output);
+
+ Tui::line('Test line', 5);
+
+ $actual = $output->fetch();
+ $this->assertStringContainsString(' Test line', $actual);
+ }
+
+ /**
+ * Test confirm in non-interactive mode (returns default).
+ */
+ public function testConfirmNonInteractive(): void {
+ $output = new BufferedOutput();
+ Tui::init($output, FALSE);
+
+ // In non-interactive mode, confirm should return the default value.
+ $result = Tui::confirm('Confirm action?', TRUE);
+ $this->assertTrue($result);
+
+ $result = Tui::confirm('Confirm action?', FALSE);
+ $this->assertFalse($result);
+ }
+
+ /**
+ * Test getChar in non-interactive mode.
+ */
+ public function testGetCharNonInteractive(): void {
+ $output = new BufferedOutput();
+ Tui::init($output, FALSE);
+
+ // In non-interactive mode, getChar should return empty string.
+ $result = Tui::getChar();
+ $this->assertEquals('', $result);
+ }
+
}
diff --git a/.vortex/tests/phpunit/Traits/SutTrait.php b/.vortex/tests/phpunit/Traits/SutTrait.php
index 26d613f1e..1d48df82b 100644
--- a/.vortex/tests/phpunit/Traits/SutTrait.php
+++ b/.vortex/tests/phpunit/Traits/SutTrait.php
@@ -74,17 +74,16 @@ protected function runInstaller(array $arguments = []): void {
if (!is_dir(static::$root . '/.vortex/installer/vendor')) {
$this->logNote('Installing dependencies of the Vortex installer');
- $this->cmd('composer --working-dir=' . static::$root . '/.vortex/installer install --no-interaction --no-progress');
+ $this->cmd('composer --working-dir=' . escapeshellarg(static::$root . '/.vortex/installer') . ' install --no-interaction --no-progress');
}
- $arguments = array_merge([
- '--no-interaction',
- static::locationsSut(),
- ], $arguments);
+ // @todo Convert options to $arguments once
+ // ProcessTrait::processParseCommand() is fixed.
+ $cmd = sprintf('php .vortex/installer/installer.php --no-interaction --destination=%s', escapeshellarg(static::locationsSut()));
$this->logNote('Run the installer script');
$this->cmd(
- 'php .vortex/installer/installer.php',
+ $cmd,
arg: $arguments,
env: static::$sutInstallerEnv + [
// Use a unique temporary directory for each installer run.
@@ -135,14 +134,14 @@ protected function buildInstaller(): string {
if (!is_dir($installer_dir)) {
$this->logNote('Installing dependencies of the Vortex installer');
- $this->cmd('composer --working-dir=' . $installer_dir . ' install --no-interaction --no-progress');
+ $this->cmd('composer --working-dir=' . escapeshellarg($installer_dir) . ' install --no-interaction --no-progress');
$this->assertDirectoryExists($installer_dir . '/vendor', 'Vortex installer vendor directory should exist after installing dependencies');
}
- $this->cmd('composer --working-dir=' . $installer_dir . ' build', env: ['SHELL_VERBOSITY' => -1], txt: 'Build the Vortex installer PHAR');
+ $this->cmd('composer --working-dir=' . escapeshellarg($installer_dir) . ' build', env: ['SHELL_VERBOSITY' => -1], txt: 'Build the Vortex installer PHAR');
$this->assertFileExists($installer_phar, 'Installer PHAR should be built');
- $this->cmd('php ' . $installer_phar . ' --version');
+ $this->cmd('php ' . escapeshellarg($installer_phar) . ' --version');
$this->logNote('Built Vortex installer: ' . trim($this->processGet()->getOutput()));
return $installer_phar;