From ce750515f232c9628adcf0ad122684089cea2a5e Mon Sep 17 00:00:00 2001 From: RocketQuack <202538874+Rocket-Quack@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:50:08 +0100 Subject: [PATCH] feat(easy-docker): add interactive bootstrap TUI with dependency handling --- easy-docker.sh | 6 + scripts/easy-docker/README.md | 24 ++ scripts/easy-docker/lib/env.sh | 387 +++++++++++++++++++++++++++++++++ scripts/easy-docker/lib/ui.sh | 60 +++++ scripts/easy-docker/main.sh | 108 +++++++++ 5 files changed, 585 insertions(+) create mode 100755 easy-docker.sh create mode 100644 scripts/easy-docker/README.md create mode 100755 scripts/easy-docker/lib/env.sh create mode 100755 scripts/easy-docker/lib/ui.sh create mode 100755 scripts/easy-docker/main.sh diff --git a/easy-docker.sh b/easy-docker.sh new file mode 100755 index 00000000..d5c09025 --- /dev/null +++ b/easy-docker.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +exec "${ROOT_DIR}/scripts/easy-docker/main.sh" "$@" diff --git a/scripts/easy-docker/README.md b/scripts/easy-docker/README.md new file mode 100644 index 00000000..d1ad7408 --- /dev/null +++ b/scripts/easy-docker/README.md @@ -0,0 +1,24 @@ +# Easy-Frappe-Docker + +Easy installation script for Frappe Docker for development and production + +## Run + +```bash +bash easy-docker.sh +``` + +## Dependencies + +- `gum` is used for the TUI +- The script checks required dependencies on startup +- Missing dependencies are installed automatically when possible +- If package manager installation for `gum` fails, the script can use a GitHub binary fallback + +## Options + +- `-h`, `--help` + - Shows usage and exits without starting the TUI +- `--no-github-binary-fallback` + - Disables GitHub binary fallback for `gum` + - If package manager installation fails, the script exits with manual installation guidance diff --git a/scripts/easy-docker/lib/env.sh b/scripts/easy-docker/lib/env.sh new file mode 100755 index 00000000..d6c7c70c --- /dev/null +++ b/scripts/easy-docker/lib/env.sh @@ -0,0 +1,387 @@ +#!/usr/bin/env bash + +command_exists() { + command -v "${1}" >/dev/null 2>&1 || command -v "${1}.exe" >/dev/null 2>&1 +} + +run_with_privileges() { + if command_exists sudo; then + sudo "$@" + return + fi + + "$@" +} + +copy_binary() { + local source_path="${1}" + local target_path="${2}" + + if command_exists install; then + install -m 0755 "${source_path}" "${target_path}" + return $? + fi + + cp "${source_path}" "${target_path}" && chmod +x "${target_path}" +} + +copy_binary_with_privileges() { + local source_path="${1}" + local target_path="${2}" + + if command_exists install; then + run_with_privileges install -m 0755 "${source_path}" "${target_path}" + return $? + fi + + run_with_privileges cp "${source_path}" "${target_path}" && + run_with_privileges chmod +x "${target_path}" +} + +detect_gum_platform() { + local raw_os="" + local raw_arch="" + + raw_os="$(uname -s 2>/dev/null || echo unknown)" + raw_arch="$(uname -m 2>/dev/null || echo unknown)" + + case "${raw_os}" in + Linux*) + GUM_OS="Linux" + ;; + Darwin*) + GUM_OS="Darwin" + ;; + MINGW* | MSYS* | CYGWIN* | Windows_NT) + GUM_OS="Windows" + ;; + *) + return 1 + ;; + esac + + case "${raw_arch}" in + x86_64 | amd64) + GUM_ARCH="x86_64" + ;; + aarch64 | arm64) + GUM_ARCH="arm64" + ;; + armv7l | armv7) + GUM_ARCH="armv7" + ;; + *) + return 1 + ;; + esac + + return 0 +} + +get_os_aliases() { + local os_name="${1}" + local os_lower="" + + os_lower="$(printf '%s' "${os_name}" | tr '[:upper:]' '[:lower:]')" + + if [ "${os_lower}" = "${os_name}" ]; then + printf '%s\n' "${os_name}" + return + fi + + printf '%s\n%s\n' "${os_name}" "${os_lower}" +} + +get_arch_aliases() { + case "${1}" in + x86_64) + printf '%s\n%s\n' "x86_64" "amd64" + ;; + arm64) + printf '%s\n%s\n' "arm64" "aarch64" + ;; + armv7) + printf '%s\n%s\n' "armv7" "armv7l" + ;; + *) + printf '%s\n' "${1}" + ;; + esac +} + +get_gum_asset_candidates() { + local release_version="${1}" + local os_alias="" + local arch_alias="" + local ext="" + + while IFS= read -r os_alias; do + while IFS= read -r arch_alias; do + for ext in tar.gz zip; do + printf 'gum_%s_%s_%s.%s\n' "${release_version}" "${os_alias}" "${arch_alias}" "${ext}" + done + done < <(get_arch_aliases "${GUM_ARCH}") + done < <(get_os_aliases "${GUM_OS}") +} + +extract_gum_asset() { + local asset_path="${1}" + local extract_dir="${2}" + + mkdir -p "${extract_dir}" + + case "${asset_path}" in + *.tar.gz) + if ! command_exists tar; then + echo "tar is required to extract gum tar.gz assets." + return 1 + fi + tar -xzf "${asset_path}" -C "${extract_dir}" + ;; + *.zip) + if ! command_exists unzip; then + echo "unzip is required to extract gum zip assets." + return 1 + fi + unzip -q "${asset_path}" -d "${extract_dir}" + ;; + *) + return 1 + ;; + esac +} + +find_gum_binary() { + local search_dir="${1}" + local found_path="" + + found_path="$( + find "${search_dir}" -type f \( -name "gum" -o -name "gum.exe" \) 2>/dev/null | + head -n 1 + )" + + if [ -n "${found_path}" ]; then + printf '%s\n' "${found_path}" + return 0 + fi + + return 1 +} + +install_gum_from_github_release() { + local release_version="" + local asset_name="" + local downloaded_asset_path="" + local download_url="" + local tmp_dir="" + local extract_dir="" + local target_dir="" + local gum_binary_path="" + local target_binary_name="gum" + + if ! command_exists curl; then + return 1 + fi + + if ! detect_gum_platform; then + echo "Unsupported platform for automatic GitHub fallback." + return 1 + fi + + release_version="$( + curl -fsSL "https://api.github.com/repos/charmbracelet/gum/releases/latest" | + sed -n 's/.*"tag_name":[[:space:]]*"v\([^"]*\)".*/\1/p' | + head -n 1 + )" + + if [ -z "${release_version}" ]; then + return 1 + fi + + tmp_dir="$(mktemp -d)" + extract_dir="${tmp_dir}/extract" + + while IFS= read -r asset_name; do + download_url="https://github.com/charmbracelet/gum/releases/download/v${release_version}/${asset_name}" + if curl -fsSL "${download_url}" -o "${tmp_dir}/${asset_name}"; then + downloaded_asset_path="${tmp_dir}/${asset_name}" + break + fi + done < <(get_gum_asset_candidates "${release_version}") + + if [ -z "${downloaded_asset_path}" ]; then + rm -rf "${tmp_dir}" + return 1 + fi + + if ! extract_gum_asset "${downloaded_asset_path}" "${extract_dir}"; then + rm -rf "${tmp_dir}" + return 1 + fi + + gum_binary_path="$(find_gum_binary "${extract_dir}" || true)" + + if [ -z "${gum_binary_path}" ]; then + rm -rf "${tmp_dir}" + return 1 + fi + + if [[ "${gum_binary_path}" == *.exe ]]; then + target_binary_name="gum.exe" + fi + + if [ "${GUM_OS}" != "Windows" ] && [ -w "/usr/local/bin" ]; then + target_dir="/usr/local/bin" + if copy_binary "${gum_binary_path}" "${target_dir}/${target_binary_name}"; then + rm -rf "${tmp_dir}" + return 0 + fi + fi + + if [ "${GUM_OS}" != "Windows" ] && command_exists sudo; then + if copy_binary_with_privileges "${gum_binary_path}" "/usr/local/bin/${target_binary_name}"; then + rm -rf "${tmp_dir}" + return 0 + fi + fi + + target_dir="${HOME}/.local/bin" + mkdir -p "${target_dir}" + if copy_binary "${gum_binary_path}" "${target_dir}/${target_binary_name}"; then + rm -rf "${tmp_dir}" + return 0 + fi + + rm -rf "${tmp_dir}" + return 1 +} + +install_gum_with_package_manager() { + local pm_attempted=0 + + if command_exists brew; then + pm_attempted=1 + if brew install gum; then + return 0 + fi + fi + + if command_exists apt-get; then + pm_attempted=1 + if run_with_privileges apt-get update && run_with_privileges apt-get install -y gum; then + return 0 + fi + fi + + if command_exists dnf; then + pm_attempted=1 + if run_with_privileges dnf install -y gum; then + return 0 + fi + fi + + if command_exists pacman; then + pm_attempted=1 + if run_with_privileges pacman -Sy --noconfirm gum; then + return 0 + fi + fi + + if command_exists zypper; then + pm_attempted=1 + if run_with_privileges zypper --non-interactive install gum; then + return 0 + fi + fi + + if command_exists winget; then + pm_attempted=1 + if winget install --id Charmbracelet.Gum -e --accept-source-agreements --accept-package-agreements; then + return 0 + fi + fi + + if command_exists choco; then + pm_attempted=1 + if choco install gum -y; then + return 0 + fi + fi + + if [ "${pm_attempted}" -eq 0 ]; then + echo "No supported package manager was found." + else + echo "Package manager installation did not succeed." + fi + + return 1 +} + +should_use_github_fallback() { + local answer="" + + if [ ! -t 0 ]; then + echo "GitHub fallback prompt requires an interactive terminal." + return 1 + fi + + printf "Use GitHub binary fallback for gum? [y/N]: " + read -r answer + + case "${answer}" in + y | Y | yes | YES) + return 0 + ;; + *) + return 1 + ;; + esac +} + +ensure_gum() { + local disable_github_binary_fallback="${1:-0}" + + if command_exists gum; then + return 0 + fi + + echo "gum is not installed. Trying package manager installation..." + + if install_gum_with_package_manager; then + hash -r + fi + + if command_exists gum; then + echo "gum was installed successfully." + return 0 + fi + + if [ "${disable_github_binary_fallback}" = "1" ]; then + echo "GitHub binary fallback is disabled." + echo "Install gum manually: https://github.com/charmbracelet/gum#installation" + echo "If installed into ~/.local/bin, add it to PATH first." + exit 1 + fi + + if should_use_github_fallback; then + echo "Trying GitHub release fallback..." + if install_gum_from_github_release; then + hash -r + fi + else + echo "GitHub fallback was not selected." + echo "Install gum manually: https://github.com/charmbracelet/gum#installation" + echo "If installed into ~/.local/bin, add it to PATH first." + exit 1 + fi + + if command_exists gum; then + echo "gum was installed successfully." + return 0 + fi + + echo "Automatic installation failed." + echo "Install gum manually: https://github.com/charmbracelet/gum#installation" + echo "If installed into ~/.local/bin, add it to PATH first." + exit 1 +} diff --git a/scripts/easy-docker/lib/ui.sh b/scripts/easy-docker/lib/ui.sh new file mode 100755 index 00000000..097ba93c --- /dev/null +++ b/scripts/easy-docker/lib/ui.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash + +render_main_screen() { + local clear_screen="${1:-0}" + local header_text="" + + if [ "${clear_screen}" = "1" ]; then + clear + fi + + header_text="$(printf "Easy Frappe Docker\nMinimal TUI bootstrap")" + + gum style \ + --border rounded \ + --border-foreground 63 \ + --padding "1 2" \ + --margin "1 2" \ + --foreground 252 \ + "${header_text}" +} + +show_main_menu() { + gum choose \ + --height 7 \ + --header "Choose an action" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Environment check" \ + "Exit" +} + +show_environment_status() { + local docker_status="not installed" + local gum_status="installed" + local status_text="" + + if command_exists docker; then + docker_status="installed" + fi + + render_main_screen 1 >&2 + + status_text="$(printf "Environment status\n\n- gum: %s\n- docker: %s" "${gum_status}" "${docker_status}")" + + gum style \ + --border rounded \ + --border-foreground 63 \ + --padding "1 2" \ + --margin "0 2" \ + --foreground 252 \ + "${status_text}" >&2 + + gum choose \ + --height 6 \ + --header "Environment actions" \ + --cursor.foreground 63 \ + --selected.foreground 45 \ + "Back to main menu" \ + "Exit and close easy-docker" +} diff --git a/scripts/easy-docker/main.sh b/scripts/easy-docker/main.sh new file mode 100755 index 00000000..b196542c --- /dev/null +++ b/scripts/easy-docker/main.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck source=scripts/easy-docker/lib/env.sh +source "${SCRIPT_DIR}/lib/env.sh" +# shellcheck source=scripts/easy-docker/lib/ui.sh +source "${SCRIPT_DIR}/lib/ui.sh" + +print_usage() { + cat <<'USAGE' +Usage: bash easy-docker.sh [options] + +Options: + --no-github-binary-fallback Disable GitHub binary fallback prompt + -h, --help Show this help +USAGE +} + +DISABLE_GITHUB_BINARY_FALLBACK=0 + +while [ "$#" -gt 0 ]; do + case "$1" in + --no-github-binary-fallback) + DISABLE_GITHUB_BINARY_FALLBACK=1 + ;; + -h | --help) + print_usage + exit 0 + ;; + *) + echo "Unknown option: $1" + print_usage + exit 1 + ;; + esac + shift +done + +ensure_gum "${DISABLE_GITHUB_BINARY_FALLBACK}" + +ALT_SCREEN_ACTIVE=0 + +enter_alt_screen() { + if [ -t 1 ] && command -v tput >/dev/null 2>&1; then + tput smcup || true + tput civis || true + ALT_SCREEN_ACTIVE=1 + fi +} + +leave_alt_screen() { + if [ "${ALT_SCREEN_ACTIVE}" = "1" ] && command -v tput >/dev/null 2>&1; then + tput cnorm || true + tput rmcup || true + ALT_SCREEN_ACTIVE=0 + fi +} + +cleanup_screen() { + leave_alt_screen +} + +cleanup_and_exit() { + exit 0 +} + +trap cleanup_and_exit INT TERM +trap cleanup_screen EXIT + +enter_alt_screen + +render_main_screen 1 + +while true; do + local_env_action="" + action="$(show_main_menu || true)" + + if [ -z "${action}" ]; then + cleanup_and_exit + fi + + case "${action}" in + "Environment check") + local_env_action="$(show_environment_status || true)" + case "${local_env_action}" in + "Back to main menu" | "") + render_main_screen 1 + ;; + "Exit and close easy-docker") + cleanup_and_exit + ;; + *) + gum style --foreground 214 "Unknown environment action: ${local_env_action}" + sleep 1 + ;; + esac + ;; + "Exit") + cleanup_and_exit + ;; + *) + gum style --foreground 214 "Unknown action: ${action}" + sleep 1 + ;; + esac +done