Skip to content

Instantly share code, notes, and snippets.

@ErHaWeb
Created March 6, 2026 11:28
Show Gist options
  • Select an option

  • Save ErHaWeb/037ab7b1d65a2781c2d830d0cf60359d to your computer and use it in GitHub Desktop.

Select an option

Save ErHaWeb/037ab7b1d65a2781c2d830d0cf60359d to your computer and use it in GitHub Desktop.
#!/usr/bin/env bash
## TYPO3 Core Contribution Setup Script
##
## This script automates the setup of a TYPO3 Core development environment
## including Git, Gerrit configuration and a fully working DDEV instance.
##
## It prepares a local TYPO3 Core checkout on the "main" branch and configures
## everything required for TYPO3 Core contribution development.
##
##
## INSTALLATION
## ----------------------------------------------------------------------
## Place the script for example in:
##
## ~/.shellscripts/typo3/install-core-contribution.sh
##
## Optionally create an alias in your shell configuration (~/.bashrc or ~/.zshrc):
##
## alias install-typo3-core="bash ~/.shellscripts/typo3/install-core-contribution.sh"
##
##
## USAGE
## ----------------------------------------------------------------------
## Pass one or more target directories as parameters. A TYPO3 Core repository
## will be cloned into each directory and fully prepared for TYPO3 Core
## contribution development.
##
## Multiple installations can be created in a single command:
##
## install-typo3-core t3core1 t3core2 t3core3
##
## Supported path variants:
##
## 1. Relative path (created in the current working directory)
##
## install-typo3-core t3coredev
##
## 2. Path relative to the user's home directory
##
## install-typo3-core ~/work/t3coredev
##
## 3. Absolute path
##
## install-typo3-core /var/www/t3coredev
##
## The target directory will be created automatically if it does not exist.
## If the directory already exists and is not empty, the script aborts.
##
##
## REQUIREMENTS
## ----------------------------------------------------------------------
## The following tools must be installed and available in PATH:
##
## - git
## - ssh
## - ddev
## - docker (required by ddev)
##
## The script verifies SSH access to TYPO3 Gerrit before setup starts:
##
## ssh -p 29418 <your-typo3-username>@review.typo3.org
##
## Your Git email must match an email address registered in Gerrit.
## Otherwise Gerrit may reject pushes with:
##
## invalid committer
##
##
## TYPO3 BACKEND LOGIN
## ----------------------------------------------------------------------
## After installation the TYPO3 backend can be accessed at:
##
## https://<ddev-project>.ddev.site/typo3/
##
## Default credentials:
##
## Username : admin
## Password : Password1%
##
## The default password is intended for local development environments only.
##
##
## COPYRIGHT
## ----------------------------------------------------------------------
## © 2026 Eric Harrer
##
## This script is intended to simplify TYPO3 Core contribution setup and
## may be adapted for personal or team development environments.
##
set -Eeuo pipefail
IFS=$'\n\t'
readonly T3CORE_RED='\033[0;31m'
readonly T3CORE_GREEN='\033[0;32m'
readonly T3CORE_YELLOW='\033[1;33m'
readonly T3CORE_NC='\033[0m'
readonly T3CORE_START_DIR="$PWD"
readonly T3CORE_DEFAULT_ADMIN_USERNAME='admin'
readonly T3CORE_DEFAULT_ADMIN_PASSWORD='Password1%'
readonly T3CORE_MIN_DDEV_VERSION='1.16.5'
readonly T3CORE_GITHUB_SSH_URL='git@github.com:typo3/typo3.git'
readonly T3CORE_GITHUB_HTTPS_URL='https://github.com/typo3/typo3.git'
readonly T3CORE_GERRIT_SSH_KEYS_URL='https://review.typo3.org/settings/web-identities#SSHKeys'
readonly T3CORE_GERRIT_IDENTITIES_URL='https://review.typo3.org/settings/web-identities#Identities'
readonly T3CORE_GERRIT_EMAIL_ADDRESSES_URL='https://review.typo3.org/settings/web-identities#EmailAddresses'
T3CORE_CLONED_TARGET_DIR=""
T3CORE_CONFIRMED_TYPO3_USERNAME=""
T3CORE_CONFIRMED_GIT_USER_NAME=""
T3CORE_CONFIRMED_GIT_USER_EMAIL=""
t3core_print_error() {
printf "%b[ERROR]%b %s\n" "$T3CORE_RED" "$T3CORE_NC" "$1" >&2
}
t3core_print_warning() {
printf "%b[WARNING]%b %s\n" "$T3CORE_YELLOW" "$T3CORE_NC" "$1"
}
t3core_print_success() {
printf "%b[OK]%b %s\n" "$T3CORE_GREEN" "$T3CORE_NC" "$1"
}
t3core_print_headline() {
printf "\n============================================================\n"
printf " %s\n" "$1"
printf "============================================================\n"
}
t3core_cleanup() {
cd "$T3CORE_START_DIR" >/dev/null 2>&1 || true
}
trap t3core_cleanup EXIT
t3core_error_trap() {
local exit_code="$?"
local line_no="$1"
local command="$2"
t3core_print_error "Command failed in line $line_no with exit code $exit_code: $command"
exit "$exit_code"
}
trap 't3core_error_trap "$LINENO" "$BASH_COMMAND"' ERR
t3core_require_command() {
local cmd="$1"
local hint="${2:-}"
if ! command -v "$cmd" >/dev/null 2>&1; then
t3core_print_error "$cmd is not installed. $hint"
exit 1
fi
}
t3core_trim() {
local value="$1"
value="${value#"${value%%[![:space:]]*}"}"
value="${value%"${value##*[![:space:]]}"}"
printf '%s' "$value"
}
t3core_prompt_with_default() {
local prompt="$1"
local default_value="${2:-}"
local input
if [ -n "$default_value" ]; then
read -r -p "$prompt [$default_value]: " input
input="$(t3core_trim "$input")"
if [ -z "$input" ]; then
input="$default_value"
fi
else
while true; do
read -r -p "$prompt: " input
input="$(t3core_trim "$input")"
if [ -n "$input" ]; then
break
fi
t3core_print_warning "This field must not be empty."
done
fi
printf '%s' "$input"
}
t3core_confirm_yes_no() {
local prompt="$1"
local answer
while true; do
read -r -p "$prompt [y/N]: " answer
answer="$(t3core_trim "$answer")"
case "$answer" in
[Yy]|[Yy][Ee][Ss]) return 0 ;;
[Nn]|[Nn][Oo]|'') return 1 ;;
*) t3core_print_warning "Please answer with y or n." ;;
esac
done
}
t3core_expand_target_path() {
local input_path="$1"
if [[ $input_path == ~/* ]]; then
printf '%s\n' "${HOME}/${input_path#~/}"
else
printf '%s\n' "$input_path"
fi
}
t3core_guard_target_path() {
local target_dir="$1"
case "$target_dir" in
""|"/"|"$HOME"|"$HOME/"|"."|"./"|".."|"../"|"/var/www"|"/var/www/")
t3core_print_error "Refusing unsafe target path: '$target_dir'"
return 1
;;
esac
return 0
}
t3core_get_version_number() {
local raw_version="$1"
printf '%s' "$raw_version" | grep -Eo '[0-9]+(\.[0-9]+)+' | head -n1
}
t3core_version_ge() {
local installed="$1"
local required="$2"
local i
local installed_part required_part
local -a installed_parts required_parts
local IFS='.'
read -r -a installed_parts <<<"$installed"
read -r -a required_parts <<<"$required"
local max_len="${#installed_parts[@]}"
if [ "${#required_parts[@]}" -gt "$max_len" ]; then
max_len="${#required_parts[@]}"
fi
for ((i = 0; i < max_len; i++)); do
installed_part="${installed_parts[i]:-0}"
required_part="${required_parts[i]:-0}"
if ((10#$installed_part > 10#$required_part)); then
return 0
fi
if ((10#$installed_part < 10#$required_part)); then
return 1
fi
done
return 0
}
t3core_require_min_version() {
local cmd="$1"
local min_version="$2"
local version_output
local installed_version
version_output="$("$cmd" --version 2>/dev/null || true)"
installed_version="$(t3core_get_version_number "$version_output")"
if [ -z "$installed_version" ]; then
t3core_print_error "Could not determine version of $cmd."
exit 1
fi
if ! t3core_version_ge "$installed_version" "$min_version"; then
t3core_print_error "$cmd version $installed_version detected but >= $min_version is required."
exit 1
fi
t3core_print_success "$cmd version $installed_version OK"
}
t3core_require_docker_running() {
if ! docker info >/dev/null 2>&1; then
t3core_print_error "Docker is installed but not running."
exit 1
fi
t3core_print_success "Docker daemon is running"
}
t3core_check_requirements() {
printf "\n--- Checking required tools\n"
t3core_require_command git "Please install Git."
t3core_require_command ssh "Please install OpenSSH."
t3core_require_command ddev "Please install DDEV."
t3core_require_command docker "Please install Docker."
t3core_require_min_version ddev "$T3CORE_MIN_DDEV_VERSION"
t3core_require_docker_running
}
t3core_get_gerrit_push_url() {
local typo3_username="$1"
printf '%s\n' "ssh://${typo3_username}@review.typo3.org:29418/Packages/TYPO3.CMS.git"
}
t3core_validate_typo3_username() {
local typo3_username="$1"
if [[ ! "$typo3_username" =~ ^[a-zA-Z0-9._-]+$ ]]; then
t3core_print_error "Invalid TYPO3 username format: '$typo3_username'"
t3core_print_error "Allowed characters are: letters, digits, dot, underscore and hyphen."
exit 1
fi
}
t3core_validate_git_email() {
local git_user_email="$1"
if [[ ! "$git_user_email" =~ ^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$ ]]; then
t3core_print_error "Invalid Git email format: '$git_user_email'"
t3core_print_error "Please enter a valid email address."
exit 1
fi
}
t3core_collect_confirmed_identity() {
local global_name
local global_email
local typo3_username
local git_user_name
local git_user_email
global_name="$(git config --global user.name 2>/dev/null || true)"
global_email="$(git config --global user.email 2>/dev/null || true)"
while true; do
printf "\n--- Please enter your TYPO3 and Git identity\n"
typo3_username="$(t3core_prompt_with_default 'YOUR_TYPO3_USERNAME' '')"
git_user_name="$(t3core_prompt_with_default 'git config user.name' "$global_name")"
git_user_email="$(t3core_prompt_with_default 'git config user.email' "$global_email")"
printf "\n--- Please confirm the information\n"
printf "YOUR_TYPO3_USERNAME : %s\n" "$typo3_username"
printf "git user.name : %s\n" "$git_user_name"
printf "git user.email : %s\n" "$git_user_email"
if t3core_confirm_yes_no "Are these values correct?"; then
t3core_validate_typo3_username "$typo3_username"
t3core_validate_git_email "$git_user_email"
T3CORE_CONFIRMED_TYPO3_USERNAME="$typo3_username"
T3CORE_CONFIRMED_GIT_USER_NAME="$git_user_name"
T3CORE_CONFIRMED_GIT_USER_EMAIL="$git_user_email"
return 0
fi
printf "\n--- Input will be requested again.\n"
done
}
t3core_print_gerrit_ssh_setup_help() {
local typo3_username="$1"
printf "\n--- Gerrit SSH setup required\n"
printf "To continue, set up SSH access for review.typo3.org:\n"
printf "\n"
printf "1. Create an SSH key if you do not have one yet:\n"
printf " ssh-keygen -t ed25519 -C \"%s\"\n" "$T3CORE_CONFIRMED_GIT_USER_EMAIL"
printf "\n"
printf "2. Open Gerrit account settings:\n"
printf " SSH keys : %s\n" "$T3CORE_GERRIT_SSH_KEYS_URL"
printf " Identities : %s\n" "$T3CORE_GERRIT_IDENTITIES_URL"
printf " Email addresses : %s\n" "$T3CORE_GERRIT_EMAIL_ADDRESSES_URL"
printf "\n"
printf "3. Add your public key, for example:\n"
printf " %s/.ssh/id_ed25519.pub\n" "$HOME"
printf "\n"
printf "4. Test the connection:\n"
printf " ssh -p 29418 %s@review.typo3.org\n" "$typo3_username"
printf "\n"
printf "Expected result:\n"
printf " Welcome to Gerrit Code Review\n"
printf " ... you have successfully connected over SSH.\n"
printf "\n"
printf "5. If your SSH client does not pick the correct key automatically,\n"
printf " add this to %s/.ssh/config:\n" "$HOME"
printf "\n"
printf " Host review.typo3.org\n"
printf " User %s\n" "$typo3_username"
printf " IdentityFile %s/.ssh/id_ed25519\n" "$HOME"
printf " Port 29418\n"
printf "\n"
printf "After that, run the script again.\n"
}
t3core_verify_gerrit_ssh_access() {
local typo3_username="$1"
local ssh_output
printf "\n--- Checking Gerrit SSH access\n"
ssh_output="$(
ssh \
-p 29418 \
-o BatchMode=yes \
-o StrictHostKeyChecking=accept-new \
"${typo3_username}@review.typo3.org" \
2>&1 || true
)"
if printf '%s' "$ssh_output" | grep -Eq 'Welcome to Gerrit Code Review|successfully connected over SSH'; then
t3core_print_success "Gerrit SSH access is working."
return 0
fi
t3core_print_error "SSH access to review.typo3.org is not configured or not working for user '${typo3_username}'."
printf "\nSSH output:\n%s\n" "$ssh_output"
t3core_print_gerrit_ssh_setup_help "$typo3_username"
exit 1
}
t3core_clone_repository() {
local target_dir_input="$1"
local target_dir
local resolved_target_dir
local ssh_clone_exit_code
local ssh_clone_output
target_dir="$(t3core_expand_target_path "$target_dir_input")"
t3core_guard_target_path "$target_dir"
printf "\n--- Cloning TYPO3 Core repository into: %b%s%b\n" "$T3CORE_GREEN" "$target_dir" "$T3CORE_NC"
mkdir -p "$target_dir"
cd -P "$target_dir" || {
t3core_print_error "Failed to change directory to $target_dir"
exit 1
}
resolved_target_dir="$(pwd -P)"
t3core_guard_target_path "$resolved_target_dir"
if [ -n "$(find . -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]; then
t3core_print_error "Target directory '$resolved_target_dir' already exists and is not empty. Aborting to protect existing files."
return 1
fi
set +e
ssh_clone_output="$(git clone "$T3CORE_GITHUB_SSH_URL" . 2>&1)"
ssh_clone_exit_code="$?"
set -e
if [ "$ssh_clone_exit_code" -eq 0 ]; then
t3core_print_success "Clone via GitHub SSH successful."
else
t3core_print_warning "GitHub SSH clone failed. Falling back to HTTPS."
printf "%s\n" "$ssh_clone_output" >&2
t3core_print_warning "GitHub SSH is optional. Gerrit SSH access remains required for pushes."
find . -mindepth 1 -maxdepth 1 -exec rm -rf {} +
git clone "$T3CORE_GITHUB_HTTPS_URL" .
t3core_print_success "Clone via GitHub HTTPS successful."
fi
T3CORE_CLONED_TARGET_DIR="$resolved_target_dir"
t3core_print_success "Repository ready in: $T3CORE_CLONED_TARGET_DIR"
}
t3core_configure_git_repository() {
local typo3_username="$1"
local git_user_name="$2"
local git_user_email="$3"
local gerrit_push_url
printf "\n--- Configuring local Git repository\n"
gerrit_push_url="$(t3core_get_gerrit_push_url "$typo3_username")"
git config --local user.name "$git_user_name"
git config --local user.email "$git_user_email"
git config --local branch.autosetuprebase remote
git config --local pull.rebase true
git config --local remote.origin.pushurl "$gerrit_push_url"
git config --local remote.origin.push "+refs/heads/main:refs/for/main"
t3core_print_success "Local Git and Gerrit configuration applied."
}
t3core_install_git_hooks() {
printf "\n--- Installing TYPO3 Git hooks for Gerrit\n"
mkdir -p .git/hooks
if [ ! -f Build/git-hooks/commit-msg ]; then
t3core_print_error "Required Gerrit hook not found: Build/git-hooks/commit-msg"
exit 1
fi
cp Build/git-hooks/commit-msg .git/hooks/commit-msg
chmod +x .git/hooks/commit-msg
if [ -f Build/git-hooks/unix+mac/pre-commit ]; then
cp Build/git-hooks/unix+mac/pre-commit .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
t3core_print_success "commit-msg and pre-commit hooks installed."
else
t3core_print_warning "Optional pre-commit hook not found. Installed commit-msg hook only."
t3core_print_success "commit-msg hook installed."
fi
}
t3core_configure_commit_template() {
local commit_template_file
commit_template_file='.git/info/typo3-commit-template.txt'
printf "\n--- Configuring TYPO3 commit message template\n"
mkdir -p .git/info
cat > "$commit_template_file" <<'EOF'
[BUGFIX|TASK|FEATURE|DOCS]
Resolves: #
Releases: main
EOF
git config --local commit.template "$commit_template_file"
t3core_print_success "Commit message template configured: $commit_template_file"
}
t3core_configure_local_git_excludes() {
local exclude_file
exclude_file='.git/info/exclude'
printf "\n--- Configuring local Git excludes\n"
mkdir -p .git/info
touch "$exclude_file"
if ! grep -Fxq '.ddev/' "$exclude_file" 2>/dev/null; then
printf "\n.ddev/\n" >> "$exclude_file"
t3core_print_success "Added .ddev/ to .git/info/exclude"
else
t3core_print_success ".ddev/ already present in .git/info/exclude"
fi
}
t3core_ensure_ddev_project_name_is_available() {
local project_name="$1"
if ddev list 2>/dev/null | tail -n +2 | awk '{print $1}' | grep -Fxq "$project_name"; then
t3core_print_error "A DDEV project named '$project_name' already exists."
t3core_print_error "The target directory name maps to the DDEV project name '$project_name'."
t3core_print_error "Please choose a different target directory name or remove the existing DDEV project first."
exit 1
fi
}
t3core_configure_ddev() {
local project_name="$1"
printf "\n--- Configuring DDEV\n"
ddev config \
--project-name="$project_name" \
--project-type=typo3 \
--docroot=. \
--database='mariadb:10.11' \
--php-version=8.2 \
--composer-version='stable' \
--webserver-type=apache-fpm \
--nodejs-version=22 \
--web-environment='TYPO3_CONTEXT=Development' \
--webimage-extra-packages='build-essential,locales-all'
t3core_print_success "DDEV configuration created."
}
t3core_setup_instance() {
local git_user_email="$1"
local project_label="$2"
printf "\n--- Installing Composer dependencies via runTests.sh\n"
if [ ! -x ./Build/Scripts/runTests.sh ]; then
t3core_print_error "Required script not found or not executable: ./Build/Scripts/runTests.sh"
exit 1
fi
./Build/Scripts/runTests.sh -s composerInstall
printf "\n--- Starting DDEV\n"
ddev start -y
printf "\n--- Creating FIRST_INSTALL file\n"
ddev exec touch FIRST_INSTALL
printf "\n--- Running TYPO3 setup\n"
ddev typo3 setup \
--driver=mysqli \
--host=db \
--port=3306 \
--dbname=db \
--username=db \
--password=db \
--admin-username="$T3CORE_DEFAULT_ADMIN_USERNAME" \
--admin-user-password="$T3CORE_DEFAULT_ADMIN_PASSWORD" \
--admin-email="$git_user_email" \
--project-name="$project_label" \
--no-interaction \
--server-type=apache \
--force
printf "\n--- Running extension:setup and activating extensions\n"
ddev typo3 extension:setup
ddev typo3 extension:activate indexed_search
ddev typo3 extension:activate styleguide
printf "\n--- Creating default backend user groups\n"
ddev typo3 setup:begroups:default --groups=Both
printf "\n--- Generating Styleguide page trees\n"
printf "# Note: 'frontend-systemplate' is the official TYPO3 identifier.\n"
ddev typo3 styleguide:generate --create -- tca
ddev typo3 styleguide:generate --create -- frontend-systemplate
printf "\n--- Flushing TYPO3 caches\n"
ddev typo3 cache:flush || true
t3core_print_success "TYPO3 Core contribution setup completed."
}
t3core_show_result() {
local project_name="$1"
local typo3_username="$2"
local target_dir="$3"
local gerrit_push_url
gerrit_push_url="$(t3core_get_gerrit_push_url "$typo3_username")"
printf "\n--- TYPO3 Backend Login\n"
printf "URL : https://%s.ddev.site/typo3/\n" "$project_name"
printf "Username : %s\n" "$T3CORE_DEFAULT_ADMIN_USERNAME"
printf "Password : %s\n" "$T3CORE_DEFAULT_ADMIN_PASSWORD"
printf "\n--- Gerrit push configuration\n"
printf "Push URL : %s\n" "$gerrit_push_url"
printf "Push ref : +refs/heads/main:refs/for/main\n"
printf "Note : This default targets main. Adjust remote.origin.push manually for other target branches.\n"
printf "\n--- Gerrit account setup\n"
printf "SSH keys : %s\n" "$T3CORE_GERRIT_SSH_KEYS_URL"
printf "Identities : %s\n" "$T3CORE_GERRIT_IDENTITIES_URL"
printf "Email addresses : %s\n" "$T3CORE_GERRIT_EMAIL_ADDRESSES_URL"
printf "\n--- Important before your first push\n"
printf "1. Add your SSH public key in Gerrit:\n"
printf " %s\n" "$T3CORE_GERRIT_SSH_KEYS_URL"
printf "\n"
printf "2. Ensure your Git email address is registered in Gerrit.\n"
printf " If Gerrit rejects your push with 'invalid committer', add the email at:\n"
printf " %s\n" "$T3CORE_GERRIT_EMAIL_ADDRESSES_URL"
printf "\n"
printf "3. Verify your connected identities at:\n"
printf " %s\n" "$T3CORE_GERRIT_IDENTITIES_URL"
printf "\n--- Test Gerrit SSH connection\n"
printf "ssh -p 29418 %s@review.typo3.org\n" "$typo3_username"
printf "\n--- Git behavior in this repository\n"
printf "git pull : rebases by default\n"
printf "git push : pushes to Gerrit for main by default\n"
printf "\n--- Suggested first contribution workflow\n"
printf "cd %s\n" "$target_dir"
printf "git status\n"
printf "git add <changed-files>\n"
printf "git commit\n"
printf "git push\n"
printf "\n--- Rebase your checkout onto latest main\n"
printf "git pull\n"
printf "\n--- Update an existing review\n"
printf "git add <changed-files>\n"
printf "git commit --amend\n"
printf "git push\n"
printf "\n--- DDEV information\n"
ddev describe || true
printf "\n--- Opening TYPO3 backend in browser\n"
ddev launch /typo3/ || true
}
t3core_execute_setup() {
local target_dir_input="$1"
local project_name
local project_label
t3core_clone_repository "$target_dir_input"
project_name="$(basename "$T3CORE_CLONED_TARGET_DIR" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9-' '-')"
project_name="${project_name#-}"
project_name="${project_name%-}"
if [ -z "$project_name" ]; then
t3core_print_error "A valid DDEV project name could not be derived from the target path."
return 1
fi
project_label="TYPO3 Contribution (${project_name})"
t3core_configure_git_repository \
"$T3CORE_CONFIRMED_TYPO3_USERNAME" \
"$T3CORE_CONFIRMED_GIT_USER_NAME" \
"$T3CORE_CONFIRMED_GIT_USER_EMAIL"
t3core_install_git_hooks
t3core_configure_commit_template
t3core_ensure_ddev_project_name_is_available "$project_name"
t3core_configure_ddev "$project_name"
t3core_configure_local_git_excludes
t3core_setup_instance \
"$T3CORE_CONFIRMED_GIT_USER_EMAIL" \
"$project_label"
t3core_show_result "$project_name" "$T3CORE_CONFIRMED_TYPO3_USERNAME" "$T3CORE_CLONED_TARGET_DIR"
cd "$T3CORE_START_DIR" >/dev/null 2>&1 || true
}
t3core_main() {
local target_dir
t3core_check_requirements
if [ "$#" -lt 1 ]; then
t3core_print_error "Please provide at least one repository path as parameter."
printf "Example: %s t3coredev\n" "${0##*/}" >&2
exit 1
fi
t3core_collect_confirmed_identity
t3core_verify_gerrit_ssh_access "$T3CORE_CONFIRMED_TYPO3_USERNAME"
for target_dir in "$@"; do
t3core_print_headline "TYPO3 Core Contribution Setup: $target_dir"
t3core_execute_setup "$target_dir"
done
}
t3core_main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment