#!/bin/sh # # lokl-cli: Lokl WordPress site launcher & manager # # Allows users to easily spin-up and manage new Lokl WordPress instances # # License: The Unlicense, https://unlicense.org # # Usage: execute this script from the project root # # run from internet: # # $ sh -c "$(curl -sSl 'https://lokl.dev/go?v=4')" # # run locally: # # $ sh cli.sh # # to skip the wizard, call the script with vars set: # # lokl_php_ver=php8-5.0.0 \ # lokl_site_name=bananapants \ # lokl_site_port=4444 \ # sh cli.sh lokl_log() { timestamp="$(date '+%H:%M:%S')" echo "$timestamp: $1" >> /tmp/lokldebuglog } set_docker_tag() { # shellcheck disable=SC2154 if [ "$lokl_php_ver" ]; then echo "$lokl_php_ver-$LOKL_RELEASE_VERSION" else echo "php8-$LOKL_RELEASE_VERSION" fi } set_site_name() { # shellcheck disable=SC2154 if [ "$lokl_site_name" ]; then echo "$lokl_site_name" else echo "" fi } set_site_port() { # shellcheck disable=SC2154 if [ "$lokl_site_port" ]; then echo "$lokl_site_port" else echo "" fi } set_curl_timeout_max_attempts() { if [ "$1" = "1" ]; then echo 2 else echo 12 fi } set_site_poll_sleep_duration() { if [ "$1" = "1" ]; then echo 0.1 else echo 5 fi } main_menu() { clear echo "" echo "================================================" echo " Lokl launcher & management script " echo "" echo " https://lokl.dev" echo "" echo "================================================" echo " Press (Ctrl) and (c) keys to exit anytime" echo "------------------------------------------------" echo "" echo "c) Create new Lokl WordPress site" echo "m) Manage my existing Lokl sites" echo "" echo "q) Quit this menu" echo "" echo "" echo "Please type (c), (m) or (q) and the Enter key: " echo "" read -r main_menu_choice if [ "$main_menu_choice" != "${main_menu_choice#[cmq]}" ]; then case $main_menu_choice in c|C) create_site_choose_name ;; m|M) manage_sites_menu ;; q|Q) exit 0 ;; esac else main_menu fi } test_core_capabilities() { clear echo "" echo "Checking system requirements... " echo "" test_docker_available test_curl_available } test_curl_available() { if ! command -v curl > /dev/null then echo "cURL doesn't seem to be installed." exit 1 fi } test_docker_available() { if ! docker run --rm hello-world > /dev/null 2>&1 then echo "Docker doesn't seem to be running or suitably configured for Lokl" exit 1 fi } create_site_choose_php_version() { # TODO: skip choice is version has been defined in a Lokl site template clear echo "" echo "Choose the PHP version for your new Lokl WordPress site. " echo "" echo "" echo "8) PHP 8.0 (recommended)" echo "7) PHP 7.4" echo "" echo "" echo "Type 8 or 7, then the Enter key: " echo "" read -r create_site_php_choice lokl_log "User input desired php version: $create_site_php_choice" if [ "$create_site_php_choice" -eq 8 ]; then LOKL_DOCKER_TAG="php8-$LOKL_RELEASE_VERSION" elif [ "$create_site_php_choice" -eq 7 ]; then LOKL_DOCKER_TAG="php7-$LOKL_RELEASE_VERSION" else create_site_choose_php_version fi create_wordpress_docker_container } create_site_choose_name() { # purposely put this after main_menu() to allow users to see what the wizard # is like before reporting any detected inabilities to create a site, which # also requires a little delay. ie, UX over logical function flow test_core_capabilities clear echo "" echo "Choose a name for your new Lokl WordPress site. " echo "" echo "Please use letters, numbers and hyphens " echo "" echo "ie, portfolio" echo "" echo "" echo "Type your site name, then the Enter key: " echo "" read -r create_site_name_choice lokl_log "User input desired sitename: $create_site_name_choice" LOKL_NAME="$(sanitize_site_name "$create_site_name_choice")" lokl_log "Sanitized sitename:: $LOKL_NAME" # check name is not empty if [ "$LOKL_NAME" = "" ]; then if [ "$LOKL_TEST_MODE" ]; then lokl_log "Empty or invalid site name entered" # early exit when testing for easier assertion exit 1 fi # re-ask for name entry if input was invalid create_site_choose_name fi lokl_log "User input site name: $LOKL_NAME" choose_lokl_site_template } choose_lokl_site_template() { lokl_log "Detecting Lokl site templates" LOKL_TEMPLATE_DIR="$HOME/.lokl/templates" # pseudo-code # if $HOME/.lokl/templates exists if [ -d "$LOKL_TEMPLATE_DIR" ]; then # and templates exist # shellcheck disable=SC2012,SC2086 template_total="$(ls $LOKL_TEMPLATE_DIR/*.lokl | wc -l)" # collect valid template names if [ "$template_total" -gt 0 ]; then clear # iterate each template file OLDIFS="$IFS" IFS=' ' # shellcheck disable=SC2086 TEMPLATE_FILES="$(ls $LOKL_TEMPLATE_DIR/*.lokl)" TEMPLATE_COUNTER=1 echo "" echo "Lokl Site Templates Found" echo "" echo "Please choose one to use for this site:" echo "" for TEMPLATE_FILE in $TEMPLATE_FILES do # TODO: validate it contains required fields (VOLUMES) TEMPLATE_NAME="$(basename "$TEMPLATE_FILE" | cut -f 1 -d '.')" # print choices for user echo "$TEMPLATE_COUNTER) $TEMPLATE_NAME" lokl_log "$TEMPLATE_COUNTER) $TEMPLATE_NAME" TEMPLATE_COUNTER=$((TEMPLATE_COUNTER+1)) done IFS="$OLDIFS" echo "" echo "0) Don't use any template for this site" echo "" # wait for user to choose from list of template names read -r choose_site_template_choice CHOSEN_TEMPLATE_INDEX="$choose_site_template_choice" lokl_log "User chose site template #$CHOSEN_TEMPLATE_INDEX" if [ "$CHOSEN_TEMPLATE_INDEX" != "0" ]; then # do the for loop again, stopping when index matches # the chosen site index, then: OLDIFS="$IFS" IFS=' ' TEMPLATE_COUNTER=1 for TEMPLATE_FILE in $TEMPLATE_FILES do if [ "$TEMPLATE_COUNTER" -eq "$CHOSEN_TEMPLATE_INDEX" ]; then # load template values lokl_log "Loading site template from $TEMPLATE_FILE" PARSE_VOLUMES="" # TODO: [[ isn't POSIX compatible # shellcheck disable=SC3010 while IFS= read -r line || [[ -n "$line" ]]; do TRIMMED_LINE="$(echo "$line" | xargs)" lokl_log "Line from file: $TRIMMED_LINE" lokl_log "Parse volumes?: $PARSE_VOLUMES" # if line equals VOLUMES, concat subsequent non empty # lines to VOLUMES_TO_MOUNT var, pipe separated if [ "$PARSE_VOLUMES" = "1" ]; then if [ -n "$TRIMMED_LINE" ]; then lokl_log "Recording volume line: $TRIMMED_LINE" # delimiter in front to allow replaceing with -v VOLUMES_TO_MOUNT="|$TRIMMED_LINE$VOLUMES_TO_MOUNT" fi fi # TODO: optimize to not check once flag set if [ "$TRIMMED_LINE" = "VOLUMES" ]; then PARSE_VOLUMES="1" fi done < "$TEMPLATE_FILE" lokl_log "Concatenated volumes to mount:" lokl_log "$VOLUMES_TO_MOUNT" # set Lokl PHP version variable # set mount paths break fi TEMPLATE_COUNTER=$((TEMPLATE_COUNTER+1)) done IFS="$OLDIFS" fi else lokl_log "Lokl site template directory didn't contain templates" fi else lokl_log "No Lokl site templates directory found" fi create_site_choose_php_version } create_wordpress_docker_container() { LOKL_PORT="$(get_random_port)" lokl_log "Random port number generated: $LOKL_PORT" lokl_log "Using Docker tag: $LOKL_DOCKER_TAG" # shellcheck disable=SC2236 if [ ! -z "$VOLUMES_TO_MOUNT" ]; then VOLUME_MOUNT_STRING="$(echo "$VOLUMES_TO_MOUNT" | sed 's/|/ -v /g')" # format volume mounting command if any set from template lokl_log "Running with mounted volumes: $VOLUME_MOUNT_STRING" # shellcheck disable=SC2086 docker run -e N="$LOKL_NAME" -e P="$LOKL_PORT" \ --name="$LOKL_NAME" -p "$LOKL_PORT":"$LOKL_PORT" \ $VOLUME_MOUNT_STRING \ -d lokl/lokl:"$LOKL_DOCKER_TAG" else lokl_log "Running without any mounted volumes" docker run -e N="$LOKL_NAME" -e P="$LOKL_PORT" \ --name="$LOKL_NAME" -p "$LOKL_PORT":"$LOKL_PORT" \ -d lokl/lokl:"$LOKL_DOCKER_TAG" fi clear echo "Launching your new Lokl WordPress site!" echo "" wait_for_site_reachable "$LOKL_NAME" "$LOKL_PORT" if [ "$LOKL_NONINTERACTIVE_MODE" ]; then lokl_log "Site successfully launched non-interactively" exit 0 fi clear echo "Your new Lokl WordPress site, $LOKL_NAME, is ready at:" echo "" echo "http://localhost:$LOKL_PORT" echo "" echo "Press any key to manage sites:" # return for assertion while testing if [ "$LOKL_TEST_MODE" ]; then lokl_log "Returning early for assertion under test runner" exit 0 fi read -r "" manage_sites_menu } wait_for_site_reachable() { lokl_name="$1" lokl_port="$2" echo "Waiting for $lokl_name to be ready at http://localhost:$lokl_port" # poll until site accessible, print progresss attempt_counter=0 max_attempts="$(set_curl_timeout_max_attempts "$LOKL_TEST_MODE")" site_poll_sleep_duration="$(set_site_poll_sleep_duration "$LOKL_TEST_MODE")" lokl_log "Waiting for: $max_attempts curl timeout attempts" until curl --output /dev/null --silent --head --fail "http://localhost:$lokl_port"; do if [ ${attempt_counter} -eq "${max_attempts}" ]; then echo "Timed out waiting for site to come online..." exit 1 fi printf '.' attempt_counter=$((attempt_counter+1)) sleep "$site_poll_sleep_duration" done } manage_sites_menu() { # purposely put this after main_menu() to allow users to see what the wizard # is like before reporting any detected inabilities to create a site, which # also requires a little delay. ie, UX over logical function flow test_core_capabilities clear echo "" echo "Your Lokl WordPress sites" echo "" LOKL_CONTAINERS="$(get_lokl_container_ids)" # handle no container if [ -z "$LOKL_CONTAINERS" ]; then echo "" echo "No site created." echo "" echo "Press any key to create a new site or q to quit: " echo "" read -r create_site_choice if [ "$create_site_choice" != "q" ]; then create_site_choose_name return 0 else exit 0 fi fi generate_site_list echo "" echo "Choose the site you want to manage." echo "" echo "Type your site's number, then the Enter key: " echo "" read -r site_to_manage_choice # check int selected is in range of available sites if [ ! -f "/tmp/lokl_containers_cache/$site_to_manage_choice" ]; then echo "Requested site not found, try again" manage_sites_menu else manage_single_site fi } start_if_stopped() { if [ "$CONTAINER_STATE" != "running" ]; then clear echo "$CONTAINER_NAME was stopped, so we're re-launching it" echo "before performing your desired action..." echo "" docker start "$CONTAINER_ID" > /dev/null # need to get container port again here # get container's exposed port CONTAINER_PORT="$(docker inspect --format='{{.NetworkSettings.Ports}}' "$CONTAINER_ID" | \ sed 's/^[^{]*{\([^{}]*\)}.*/\1/' | awk '{print $2}')" wait_for_site_reachable "$CONTAINER_NAME" "$CONTAINER_PORT" fi } kill_container() { clear echo "Are you sure you want to force quit $CONTAINER_NAME?" echo "" echo "Type 'y' for yes:" read -r confirm_kill_container if [ "$confirm_kill_container" != "y" ]; then manage_single_site else echo "Stopping $CONTAINER_NAME's server." echo "" echo "Lokl will attempt to launch it again as you need it" echo "" docker kill "$CONTAINER_ID" > /dev/null fi } delete_container() { clear echo "Are you sure you want to delete $CONTAINER_NAME completely?" echo "" echo "Type 'y' for yes:" read -r confirm_delete_container if [ "$confirm_delete_container" != "y" ]; then manage_single_site else echo "Deleting $CONTAINER_NAME completely." echo "" docker rm "$CONTAINER_ID" > /dev/null fi } manage_single_site() { clear # load lokl container info from cache file CONTAINER_INFO=$(cat "/tmp/lokl_containers_cache/$site_to_manage_choice") CONTAINER_ID=$(echo "$CONTAINER_INFO" | cut -f1 -d,) CONTAINER_NAME=$(echo "$CONTAINER_INFO" | cut -f2 -d,) CONTAINER_PORT=$(echo "$CONTAINER_INFO" | cut -f3 -d,) CONTAINER_STATE=$(echo "$CONTAINER_INFO" | cut -f4 -d,) # print out details echo "Site: $CONTAINER_NAME" echo "Status: $CONTAINER_STATE" echo "" echo "Choose action to perform: " echo "" echo "o) open site http://localhost:$CONTAINER_PORT" echo "a) open WordPress admin /wp-admin" echo "p) open phpMyAdmin /phpmyadmin" echo "s) SSH into container" echo "t) take backup of site files and database" echo "l) follow server error logs" if [ "$CONTAINER_STATE" = "running" ]; then echo "k) kill (force quit) site's server" fi if [ "$CONTAINER_STATE" != "running" ]; then echo "d) delete server and site completely" fi echo "" echo "m) Back to manage sites menu" echo "q) Quit this menu" echo "" read -r site_action_choice if [ "$site_action_choice" != "${site_action_choice#[oapstlkdmq]}" ]; then case $site_action_choice in o|O) open_site_in_browser ;; a|A) open_wordpress_admin ;; p|P) open_phpmyadmin ;; s|S) ssh_into_container ;; t|T) take_site_backup ;; l|L) follow_error_logs ;; m|M) manage_sites_menu ;; k|K) kill_container ;; d|D) delete_container ;; q|Q) exit 0 ;; esac else manage_single_site fi } # take DB and files backup of site take_site_backup() { start_if_stopped clear echo "Generating backup file in container..." echo "" docker exec -it "$CONTAINER_ID" /backup_site.sh echo "Saving backup to host computer in path:" echo "" echo "/tmp/${CONTAINER_NAME}_SITE_BACKUP.tar.gz" echo "" docker cp "$CONTAINER_ID:/tmp/${CONTAINER_NAME}_SITE_BACKUP.tar.gz" \ "/tmp/${CONTAINER_NAME}_SITE_BACKUP.tar.gz" # ensure file was generated if [ ! -f "/tmp/${CONTAINER_NAME}_SITE_BACKUP.tar.gz" ]; then echo "Failed to save backup, try again" exit 1 else echo "Backup complete" echo "" exit 0 fi } # shell connect to container using Docker ssh_into_container() { start_if_stopped clear echo "Connecting to $CONTAINER_NAME via SSH" echo "" docker exec -it "$CONTAINER_ID" /bin/sh } follow_error_logs() { start_if_stopped clear echo "Following error logs for $CONTAINER_NAME:" echo "" docker logs -f "$CONTAINER_ID" } # open site in default browser open_site_in_browser() { start_if_stopped SITE_URL="http://localhost:$CONTAINER_PORT" if command -v xdg-open > /dev/null; then clear echo "Opening $SITE_URL in your browser." xdg-open "$SITE_URL" elif command -v gnome-open > /dev/null; then clear echo "Opening $SITE_URL in your browser." gnome-open "$SITE_URL" elif open -Ra "safari" ; then clear echo "Opening $SITE_URL in Safari." open -a safari "$SITE_URL" else echo "Couldn't detect the web browser to use." echo "" echo "Please manually open this URL in your browser:" echo "" echo "$SITE_URL" fi } open_wordpress_admin() { start_if_stopped SITE_URL="http://localhost:$CONTAINER_PORT/wp-admin/" if command -v xdg-open > /dev/null; then clear echo "Opening $SITE_URL in your browser." xdg-open "$SITE_URL" elif command -v gnome-open > /dev/null; then clear echo "Opening $SITE_URL in your browser." gnome-open "$SITE_URL" elif open -Ra "safari" ; then clear echo "Opening $SITE_URL in Safari." open -a safari "$SITE_URL" else echo "Couldn't detect the web browser to use." echo "" echo "Please manually open this URL in your browser:" echo "" echo "$SITE_URL" fi } open_phpmyadmin() { start_if_stopped SITE_URL="http://localhost:$CONTAINER_PORT/phpmyadmin/" if command -v xdg-open > /dev/null; then clear echo "Opening $SITE_URL in your browser." xdg-open "$SITE_URL" elif command -v gnome-open > /dev/null; then clear echo "Opening $SITE_URL in your browser." gnome-open "$SITE_URL" elif open -Ra "safari" ; then clear echo "Opening $SITE_URL in Safari." open -a safari "$SITE_URL" else echo "Couldn't detect the web browser to use." echo "" echo "Please manually open this URL in your browser:" echo "" echo "$SITE_URL" fi } get_random_port() { if [ "$LOKL_PORT" ]; then echo "$LOKL_PORT" return 0 fi # echo value to stdout to be used in cmd substitution awk -v min=4000 -v max=5000 'BEGIN{srand(); print int(min+rand()*(max-min+1))}' # TODO: check for unused port to avoid collision } get_lokl_container_ids() { docker ps -a | awk '{ print $1,$2 }' | grep lokl | awk '{print $1 }' } generate_site_list() { # empty flatfile lokl containers cache rm -Rf /tmp/lokl_containers_cache/* mkdir -p /tmp/lokl_containers_cache/ SITE_COUNTER=1 # POSIX compliant way to iterate a list OLDIFS="$IFS" IFS=' ' for CONTAINER_ID in $LOKL_CONTAINERS do CONTAINER_NAME="$(get_container_name_from_id "$CONTAINER_ID")" CONTAINER_PORT="$(get_container_port_from_id "$CONTAINER_ID")" CONTAINER_STATE="$(get_container_state_from_id "$CONTAINER_ID")" # print choices for user echo "$SITE_COUNTER) $CONTAINER_NAME" # append choices in cache file named for site counter (brittle internal ID) echo "$CONTAINER_ID,$CONTAINER_NAME,$CONTAINER_PORT,$CONTAINER_STATE" >> /tmp/lokl_containers_cache/$SITE_COUNTER SITE_COUNTER=$((SITE_COUNTER+1)) done IFS="$OLDIFS" } get_container_name_from_id() { docker inspect --format='{{.Name}}' "$1" | sed 's|/||' } get_container_port_from_id() { docker inspect --format='{{.NetworkSettings.Ports}}' "$1" | \ sed 's/^[^{]*{\([^{}]*\)}.*/\1/' | awk '{print $2}' } get_container_state_from_id() { docker inspect --format='{{.State.Status}}' "$1" } sanitize_site_name() { USER_SITE_NAME_CHOICE="$1" # strip all non-alpha characters from string, converts to lowercase # trims all hyphens # trim to 100 chars if over echo "$USER_SITE_NAME_CHOICE" | tr -cd '[:alnum:]-' | \ tr '[:upper:]' '[:lower:]' | sed 's/--//g' | sed 's/^-//' | sed 's/-$//' | \ cut -c1-100 } # if running tests, export var to use as flag within functions # TODO: could put this back in spec_helper, but may annoy shellcheck if [ "${__SOURCED__}" ]; then lokl_log "### LOKL TEST MODE ENABLED ###" export LOKL_TEST_MODE=1 fi LOKL_DOCKER_TAG="$(set_docker_tag)" LOKL_NAME="$(set_site_name)" LOKL_PORT="$(set_site_port)" LOKL_RELEASE_VERSION="5.0.0" VOLUMES_TO_MOUNT="" lokl_log "Using Docker tag: $LOKL_DOCKER_TAG" # allow testing without entering menu, using shellspec's var ${__SOURCED__:+return} # skip menu if minimum required arguments are set if [ "${LOKL_NAME}" ]; then export LOKL_NONINTERACTIVE_MODE=1 lokl_log "Skipping wizard" lokl_log "Site Name Argument Passed: $LOKL_NAME" lokl_log "Site Port Argument Passed: $LOKL_PORT" lokl_log "Docker Tag Argument Passed: $LOKL_DOCKER_TAG" create_wordpress_docker_container else main_menu fi exit 0