diff --git a/bin/composer b/bin/composer index 292f617..745be1f 100755 --- a/bin/composer +++ b/bin/composer @@ -1,24 +1,21 @@ #!/usr/bin/env bash set -euo pipefail +# Composer runner for LocalDevStack +# - Prefers running PHP_X.Y containers +# - Falls back to SERVER_TOOLS when no PHP container satisfies the request +# - Runs composer in a throwaway container using the selected image +# - Shares the selected container network namespace + die() { echo "Error: $*" >&2 exit 1 } -# ───────────────────────────────────────────────────────────────────────────── -# Repo layout: -# /bin/composer (this script) -# /docker/.env -# /configuration/ssh -# ───────────────────────────────────────────────────────────────────────────── ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ENV_FILE="$ROOT_DIR/docker/.env" SSH_DIR="$ROOT_DIR/configuration/ssh" -# ───────────────────────────────────────────────────────────────────────────── -# Pretty output (auto-disable when stderr isn't a TTY) -# ───────────────────────────────────────────────────────────────────────────── if [[ -t 2 ]]; then C_RESET=$'\033[0m' C_CYAN=$'\033[0;36m' @@ -34,19 +31,6 @@ kv() { printf "%s%s%s %s\n" "$C_CYAN" "$title" "$C_RESET" "$value" >&2 } -# ───────────────────────────────────────────────────────────────────────────── -# Load docker/.env safely (skip UID/EUID because bash has readonly UID/EUID) -# ───────────────────────────────────────────────────────────────────────────── -if [[ -r "$ENV_FILE" ]]; then - set -o allexport - # shellcheck disable=SC1090 - source <(grep -vE '^(UID|EUID)=' "$ENV_FILE") - set +o allexport -fi - -# ───────────────────────────────────────────────────────────────────────────── -# MSYS/Git Bash detection (parser-safe) -# ───────────────────────────────────────────────────────────────────────────── is_msys() { case "${OSTYPE:-}" in msys* | cygwin*) return 0 ;; @@ -54,10 +38,6 @@ is_msys() { esac } -# ───────────────────────────────────────────────────────────────────────────── -# MSYS/Git Bash: prevent path auto-conversion when calling docker.exe -# Fixes: "-w /workspace" becoming "-w D:/Program Files/Git/workspace" -# ───────────────────────────────────────────────────────────────────────────── msys_no_pathconv() { if is_msys; then export MSYS_NO_PATHCONV=1 @@ -65,29 +45,65 @@ msys_no_pathconv() { fi } -# ───────────────────────────────────────────────────────────────────────────── -# Resolve host working directory for Docker Desktop bind mount. -# Priority: -# 1) WORKDIR_WIN (from server.bat wrapper) -# 2) WORKING_DIR (from docker/.env) -# 3) $PWD -# 4) repo root (safe fallback) -# NOTE: host path should be Windows absolute for Docker Desktop on Windows. -# ───────────────────────────────────────────────────────────────────────────── +trim() { + local s="$1" + s="${s#"${s%%[![:space:]]*}"}" + s="${s%"${s##*[![:space:]]}"}" + printf '%s' "$s" +} + +strip_quotes() { + local v + v="$(trim "$1")" + if [[ "$v" =~ ^\"(.*)\"$ ]]; then + printf '%s' "${BASH_REMATCH[1]}" + return 0 + fi + if [[ "$v" =~ ^\'(.*)\'$ ]]; then + printf '%s' "${BASH_REMATCH[1]}" + return 0 + fi + printf '%s' "$v" +} + +load_env_file() { + [[ -r "$ENV_FILE" ]] || return 0 + + local line key value + while IFS= read -r line || [[ -n "$line" ]]; do + line="$(trim "$line")" + [[ -z "$line" || "${line:0:1}" == "#" ]] && continue + [[ "$line" == export* ]] && line="$(trim "${line#export}")" + [[ "$line" == *=* ]] || continue + + key="${line%%=*}" + value="${line#*=}" + key="$(trim "$key")" + value="$(strip_quotes "$value")" + + case "$key" in + UID | EUID | '') + continue + ;; + *) + export "$key=$value" + ;; + esac + done <"$ENV_FILE" +} + resolve_host_pwd() { local host="${WORKDIR_WIN:-${WORKING_DIR:-}}" - if [[ -z "${host}" ]]; then + if [[ -z "$host" ]]; then host="$PWD" fi - [[ -n "${host}" ]] || host="$ROOT_DIR" + [[ -n "$host" ]] || host="$ROOT_DIR" if command -v cygpath >/dev/null 2>&1; then - # Convert MSYS /c/... -> C:\... for docker.exe bind mount [[ "$host" == /* ]] && host="$(cygpath -w "$host")" fi - # Guard: if launched in Git install "workspace", fallback to repo root case "$host" in *"Program Files/Git/workspace"* | *"Program Files\\Git\\workspace"*) if command -v cygpath >/dev/null 2>&1; then @@ -101,37 +117,55 @@ resolve_host_pwd() { printf '%s' "$host" } -# ───────────────────────────────────────────────────────────────────────────── -# Version helpers -# ───────────────────────────────────────────────────────────────────────────── +resolve_source_dir() { + local host="$1" + + if [[ -d "$host" ]]; then + printf '%s' "$host" + return 0 + fi + + if command -v cygpath >/dev/null 2>&1; then + if host="$(cygpath -u "$host" 2>/dev/null)" && [[ -d "$host" ]]; then + printf '%s' "$host" + return 0 + fi + fi + + printf '%s' "$PWD" +} + ver_norm() { local v="$1" - if [[ "$v" =~ ^[0-9]+(\.[0-9]+){0,2}$ ]]; then - local a b c - IFS='.' read -r a b c <<<"$v" - b="${b:-0}" - c="${c:-0}" - echo "${a}.${b}.${c}" - else echo "$v"; fi + local a b c + IFS='.' read -r a b c <<<"$v" + a="${a:-0}" + b="${b:-0}" + c="${c:-0}" + printf '%d.%d.%d' "$a" "$b" "$c" } + ver_cmp_ge() { local a b a="$(ver_norm "$1")" b="$(ver_norm "$2")" - [[ "$(printf "%s\n%s\n" "$b" "$a" | sort -V | tail -n1)" == "$a" ]] + [[ "$(printf '%s\n%s\n' "$b" "$a" | sort -V | tail -n1)" == "$a" ]] } + ver_cmp_gt() { local a b a="$(ver_norm "$1")" b="$(ver_norm "$2")" [[ "$a" != "$b" ]] && ver_cmp_ge "$a" "$b" } + ver_cmp_le() { local a b a="$(ver_norm "$1")" b="$(ver_norm "$2")" - [[ "$(printf "%s\n%s\n" "$a" "$b" | sort -V | head -n1)" == "$a" ]] + [[ "$(printf '%s\n%s\n' "$a" "$b" | sort -V | head -n1)" == "$a" ]] } + ver_cmp_lt() { local a b a="$(ver_norm "$1")" @@ -139,145 +173,276 @@ ver_cmp_lt() { [[ "$a" != "$b" ]] && ver_cmp_le "$a" "$b" } -read_php_constraint() { - [[ -f composer.json ]] || return 1 +version_in_range() { + local cand="$1" lower="$2" upper="$3" + ver_cmp_ge "$cand" "$lower" && ver_cmp_lt "$cand" "$upper" +} - if command -v jq >/dev/null 2>&1; then - local v - v="$(jq -r '.require.php // empty' composer.json 2>/dev/null || true)" - [[ -n "$v" && "$v" != "null" ]] && { - echo "$v" - return 0 - } +next_minor() { + local major minor + IFS='.' read -r major minor _ <<<"$(ver_norm "$1")" + printf '%d.%d.0' "$major" "$((minor + 1))" +} + +next_major() { + local major + IFS='.' read -r major _ <<<"$(ver_norm "$1")" + printf '%d.0.0' "$((major + 1))" +} + +caret_upper_bound() { + local major minor patch + IFS='.' read -r major minor patch <<<"$(ver_norm "$1")" + + if ((major > 0)); then + printf '%d.0.0' "$((major + 1))" + elif ((minor > 0)); then + printf '0.%d.0' "$((minor + 1))" + else + printf '0.0.%d' "$((patch + 1))" fi +} - if command -v python3 >/dev/null 2>&1; then - python3 - <<'PY' 2>/dev/null || true -import json -try: - with open("composer.json","r",encoding="utf-8") as f: - data=json.load(f) - v=(data.get("require") or {}).get("php") or "" - if isinstance(v,str) and v.strip(): - print(v.strip()) -except Exception: - pass -PY +constraint_atom_allows() { + local raw="$1" cand="$2" + local tok + tok="$(trim "$raw")" + [[ -z "$tok" ]] && return 0 + + tok="${tok#v}" + tok="${tok#V}" + tok="${tok%%-dev}" + tok="${tok%%+*}" + tok="${tok%%@*}" + + case "$tok" in + '*' | 'x' | 'X') return 0 + ;; + esac + + if [[ "$tok" =~ ^([0-9]+)$ ]]; then + version_in_range "$cand" "${BASH_REMATCH[1]}.0.0" "$((BASH_REMATCH[1] + 1)).0.0" + return fi - local line - line="$(grep -Eo '"php"[[:space:]]*:[[:space:]]*"[^"]+"' composer.json 2>/dev/null | head -n1 || true)" - [[ -n "$line" ]] || return 1 - echo "$line" | sed -E 's/.*"php"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' -} + if [[ "$tok" =~ ^([0-9]+)[.](x|X|\*)$ ]]; then + version_in_range "$cand" "${BASH_REMATCH[1]}.0.0" "$((BASH_REMATCH[1] + 1)).0.0" + return + fi -constraint_allows() { - local constraint="$1" cand="$2" - local cand_full - cand_full="$(ver_norm "$cand")" - [[ -z "${constraint// /}" ]] && return 0 - local c="$constraint" - c="${c//,/ }" - - local IFS=$'\n' parts=() - # shellcheck disable=SC2207 - parts=($(echo "$c" | sed -E 's/[[:space:]]*\|\|[[:space:]]*/\n/g; s/[[:space:]]+\|[[:space:]]+/\n/g')) - - local part tok - for part in "${parts[@]}"; do - part="${part#"${part%%[![:space:]]*}"}" - part="${part%"${part##*[![:space:]]}"}" - [[ -z "$part" ]] && continue - - local ok=true - for tok in $part; do - tok="${tok#"${tok%%[![:space:]]*}"}" - tok="${tok%"${tok##*[![:space:]]}"}" - [[ -z "$tok" ]] && continue - - if [[ "$tok" == "*" || "$tok" == "x" || "$tok" == "X" ]]; then continue; fi - if [[ "$tok" =~ ^[0-9]+\.x$ || "$tok" =~ ^[0-9]+\.X$ || "$tok" =~ ^[0-9]+\.\*$ ]]; then - [[ "${cand%%.*}" == "${tok%%.*}" ]] || ok=false - continue - fi - if [[ "$tok" =~ ^[0-9]+\.[0-9]+\.x$ || "$tok" =~ ^[0-9]+\.[0-9]+\.X$ || "$tok" =~ ^[0-9]+\.[0-9]+\.?\*$ ]]; then - [[ "$cand" == "${tok%.*}" ]] || ok=false - continue - fi + if [[ "$tok" =~ ^([0-9]+)[.]([0-9]+)$ ]]; then + version_in_range "$cand" "${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.0" "$(next_minor "${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.0")" + return + fi - if [[ "$tok" =~ ^\^([0-9]+)\.([0-9]+) ]]; then - local maj="${BASH_REMATCH[1]}" min="${BASH_REMATCH[2]}" - local lower="${maj}.${min}" upper="$((maj + 1)).0" - ver_cmp_ge "$cand_full" "$(ver_norm "$lower")" && ver_cmp_lt "$cand_full" "$(ver_norm "$upper")" || ok=false - continue - fi + if [[ "$tok" =~ ^([0-9]+)[.]([0-9]+)[.](x|X|\*)$ ]]; then + version_in_range "$cand" "${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.0" "$(next_minor "${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.0")" + return + fi - if [[ "$tok" =~ ^~([0-9]+)\.([0-9]+) ]]; then - local maj="${BASH_REMATCH[1]}" min="${BASH_REMATCH[2]}" - local lower="${maj}.${min}" upper="${maj}.$((min + 1))" - ver_cmp_ge "$cand_full" "$(ver_norm "$lower")" && ver_cmp_lt "$cand_full" "$(ver_norm "$upper")" || ok=false - continue - fi + if [[ "$tok" =~ ^([0-9]+)[.]([0-9]+)[.]([0-9]+)$ ]]; then + version_in_range "$cand" "${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}" "$(next_minor "${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.0")" + return + fi - if [[ "$tok" =~ ^(>=|<=|>|<)([0-9]+(\.[0-9]+)?)$ ]]; then - local op="${BASH_REMATCH[1]}" v="${BASH_REMATCH[2]}" - case "$op" in - '>=') ver_cmp_ge "$cand_full" "$(ver_norm "$v")" || ok=false ;; - '<=') ver_cmp_le "$cand_full" "$(ver_norm "$v")" || ok=false ;; - '>') ver_cmp_gt "$cand_full" "$(ver_norm "$v")" || ok=false ;; - '<') ver_cmp_lt "$cand_full" "$(ver_norm "$v")" || ok=false ;; - esac - continue - fi + if [[ "$tok" =~ ^\^([0-9]+(?:\.[0-9]+){0,2})$ ]]; then + local lower upper + lower="$(ver_norm "${BASH_REMATCH[1]}")" + upper="$(caret_upper_bound "$lower")" + version_in_range "$cand" "$lower" "$upper" + return + fi - if [[ "$tok" =~ ^([0-9]+)\.([0-9]+)$ ]]; then - [[ "$cand" == "${BASH_REMATCH[1]}.${BASH_REMATCH[2]}" ]] || ok=false - continue - fi - : - done + if [[ "$tok" =~ ^~([0-9]+(?:\.[0-9]+){0,2})$ ]]; then + local base normalized upper + base="${BASH_REMATCH[1]}" + normalized="$(ver_norm "$base")" + if [[ "$base" =~ ^[0-9]+$ ]]; then + upper="$(next_major "$normalized")" + elif [[ "$base" =~ ^[0-9]+\.[0-9]+$ ]]; then + upper="$(next_major "$normalized")" + else + upper="$(next_minor "$normalized")" + fi + version_in_range "$cand" "$normalized" "$upper" + return + fi - [[ "$ok" == true ]] && return 0 + if [[ "$tok" =~ ^(>=|<=|>|<|=)[[:space:]]*([0-9]+(?:\.[0-9]+){0,2})$ ]]; then + local op="${BASH_REMATCH[1]}" v="$(ver_norm "${BASH_REMATCH[2]}")" + case "$op" in + '>=') ver_cmp_ge "$cand" "$v" ;; + '<=') ver_cmp_le "$cand" "$v" ;; + '>') ver_cmp_gt "$cand" "$v" ;; + '<') ver_cmp_lt "$cand" "$v" ;; + '=') version_in_range "$cand" "$v" "$(next_minor "$v")" ;; + esac + return + fi + + return 1 +} + +constraint_and_allows() { + local expr="$1" cand="$2" + local normalized + + normalized="$expr" + normalized="${normalized//,/ }" + normalized="${normalized//>=/>= }" + normalized="${normalized//<=/<= }" + normalized="${normalized///> }" + normalized="${normalized//= / = }" + normalized="${normalized//= /= }" + normalized="$(trim "$normalized")" + + local tok + for tok in $normalized; do + [[ -z "$tok" ]] && continue + constraint_atom_allows "$tok" "$cand" || return 1 done + + return 0 +} + +constraint_allows() { + local constraint="$1" cand="$2" + local expr + constraint="$(trim "$constraint")" + [[ -z "$constraint" ]] && return 0 + + while IFS= read -r expr; do + expr="$(trim "$expr")" + [[ -z "$expr" ]] && continue + if constraint_and_allows "$expr" "$cand"; then + return 0 + fi + done < <(printf '%s\n' "$constraint" | sed -E 's/[[:space:]]*\|\|[[:space:]]*/\n/g; s/[[:space:]]+\|[[:space:]]+/\n/g') + return 1 } +read_php_constraint_from() { + local dir="$1" + local file="$dir/composer.json" + [[ -f "$file" ]] || return 1 + + if command -v jq >/dev/null 2>&1; then + local v + v="$(jq -r '.require.php // empty' "$file" 2>/dev/null || true)" + [[ -n "$v" && "$v" != "null" ]] && { + printf '%s' "$v" + return 0 + } + fi + + local line + line="$(grep -Eo '"php"[[:space:]]*:[[:space:]]*"[^"]+"' "$file" 2>/dev/null | head -n1 || true)" + [[ -n "$line" ]] || return 1 + printf '%s' "$line" | sed -E 's/.*"php"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/' +} + running_php_versions() { docker ps --format '{{.Names}}' | awk -F'_' '/^PHP_[0-9]+\.[0-9]+$/ {print $2}' | sort -Vr } -pick_container_by_version() { echo "PHP_$1"; } -pick_best_container() { - local explicit_ver="$1" constraint="${2:-}" +php_container_for_version() { + printf 'PHP_%s' "$1" +} + +container_is_running() { + local name="$1" + docker ps --format '{{.Names}}' | grep -qx "$name" +} + +get_container_image() { + docker inspect -f '{{.Config.Image}}' "$1" 2>/dev/null +} + +server_tools_php_version() { + container_is_running SERVER_TOOLS || return 1 + docker exec SERVER_TOOLS sh -lc 'php -r "echo PHP_MAJOR_VERSION, \".\", PHP_MINOR_VERSION;"' 2>/dev/null | tr -d '\r\n' +} + +select_runtime() { + local explicit_ver="$1" constraint="$2" local versions=() + local matched=() + local st_ver="" + mapfile -t versions < <(running_php_versions) - [[ ${#versions[@]} -gt 0 ]] || die "no PHP container is running (expected names like PHP_8.4)." + + if container_is_running SERVER_TOOLS; then + st_ver="$(server_tools_php_version || true)" + fi if [[ -n "$explicit_ver" ]]; then local cname - cname="$(pick_container_by_version "$explicit_ver")" - docker ps --format '{{.Names}}' | grep -qx "$cname" || die "container '$cname' is not running." - printf "%s\n%s\n%s\n" "$cname" "$explicit_ver" "explicit via -V" - return 0 + cname="$(php_container_for_version "$explicit_ver")" + + if container_is_running "$cname"; then + printf '%s\n%s\n%s\n%s\n' "$cname" "$explicit_ver" "explicit via -V" "container" + return 0 + fi + + if [[ -n "$st_ver" && "$st_ver" == "$explicit_ver" ]]; then + printf '%s\n%s\n%s\n%s\n' "SERVER_TOOLS" "$st_ver" "explicit via -V" "server_tools" + return 0 + fi + + if [[ -n "$st_ver" ]]; then + die "requested PHP $explicit_ver, but '$cname' is not running and SERVER_TOOLS provides PHP $st_ver." + fi + + die "requested PHP $explicit_ver, but '$cname' is not running and SERVER_TOOLS is unavailable." fi - if [[ -n "${constraint// /}" ]]; then + if [[ -n "$constraint" ]]; then local v for v in "${versions[@]}"; do if constraint_allows "$constraint" "$v"; then - printf "%s\n%s\n%s\n" "$(pick_container_by_version "$v")" "$v" "composer.json: ${constraint}" - return 0 + matched+=("$v") fi done + + if [[ ${#matched[@]} -gt 0 ]]; then + printf '%s\n%s\n%s\n%s\n' "$(php_container_for_version "${matched[0]}")" "${matched[0]}" "composer.json: $constraint" "container" + return 0 + fi + + if [[ -n "$st_ver" ]] && constraint_allows "$constraint" "$st_ver"; then + printf '%s\n%s\n%s\n%s\n' "SERVER_TOOLS" "$st_ver" "composer.json: $constraint" "server_tools" + return 0 + fi + + if [[ ${#versions[@]} -gt 0 ]]; then + die "no running PHP container satisfies composer.json PHP constraint '$constraint'; SERVER_TOOLS${st_ver:+ ($st_ver)} also does not satisfy it." + fi + + if [[ -n "$st_ver" ]]; then + die "SERVER_TOOLS is running with PHP $st_ver, which does not satisfy composer.json PHP constraint '$constraint'." + fi + + die "no PHP runtime available that satisfies composer.json PHP constraint '$constraint'." + fi + + if [[ ${#versions[@]} -gt 0 ]]; then + printf '%s\n%s\n%s\n%s\n' "$(php_container_for_version "${versions[0]}")" "${versions[0]}" "fallback: highest running container" "container" + return 0 fi - printf "%s\n%s\n%s\n" "$(pick_container_by_version "${versions[0]}")" "${versions[0]}" "fallback: highest running container" + if [[ -n "$st_ver" ]]; then + printf '%s\n%s\n%s\n%s\n' "SERVER_TOOLS" "$st_ver" "fallback: SERVER_TOOLS" "server_tools" + return 0 + fi + + die "no PHP_X.Y container is running and SERVER_TOOLS is unavailable." } -# ───────────────────────────────────────────────────────────────────────────── -# Args -# ───────────────────────────────────────────────────────────────────────────── +load_env_file + EXPLICIT_VER="" ARGS=() @@ -300,44 +465,62 @@ while [[ $# -gt 0 ]]; do esac done +HOST_PWD="$(resolve_host_pwd)" +SOURCE_DIR="$(resolve_source_dir "$HOST_PWD")" PHP_CONSTRAINT="" -[[ -z "$EXPLICIT_VER" ]] && PHP_CONSTRAINT="$(read_php_constraint || true)" + +if [[ -z "$EXPLICIT_VER" ]]; then + PHP_CONSTRAINT="$(read_php_constraint_from "$SOURCE_DIR" || true)" +fi TARGET_CONTAINER="" DETECTED_VER="" DETECT_REASON="" +RUNTIME_KIND="" { read -r TARGET_CONTAINER read -r DETECTED_VER read -r DETECT_REASON -} < <(pick_best_container "$EXPLICIT_VER" "$PHP_CONSTRAINT") + read -r RUNTIME_KIND +} < <(select_runtime "$EXPLICIT_VER" "$PHP_CONSTRAINT") -IMAGE="$(docker inspect -f '{{.Config.Image}}' "$TARGET_CONTAINER" 2>/dev/null)" || - die "failed to inspect container '$TARGET_CONTAINER'." +IMAGE="$(get_container_image "$TARGET_CONTAINER")" || die "failed to inspect container '$TARGET_CONTAINER'." kv "PHP detected:" "${C_YELLOW}${DETECTED_VER}${C_RESET} ${C_CYAN}(${DETECT_REASON})${C_RESET}" +kv "Runtime:" "${C_YELLOW}${RUNTIME_KIND}${C_RESET}" kv "Container:" "${C_YELLOW}${TARGET_CONTAINER}${C_RESET}" kv "Image:" "${C_YELLOW}${IMAGE}${C_RESET}" -HOST_PWD="$(resolve_host_pwd)" msys_no_pathconv -RUN_FLAGS=(--rm -v "$HOST_PWD":/workspace -w /workspace --network "container:$TARGET_CONTAINER") -[[ -t 0 ]] && RUN_FLAGS+=(-it) || RUN_FLAGS+=(-i) -RUN_FLAGS+=(-u "$(id -u):$(id -g)") +RUN_FLAGS=( + --rm + -v "$HOST_PWD:/workspace" + -w /workspace + --network "container:$TARGET_CONTAINER" + -e HOME=/tmp/composer-home + -e COMPOSER_HOME=/tmp/composer-home + -e COMPOSER_CACHE_DIR=/tmp/composer-cache +) + +if [[ -t 0 && -t 1 ]]; then + RUN_FLAGS+=(-it) +else + RUN_FLAGS+=(-i) +fi + +if command -v id >/dev/null 2>&1; then + RUN_FLAGS+=(-u "$(id -u):$(id -g)") +fi if [[ -d "$SSH_DIR" ]]; then - RUN_FLAGS+=(-v "$SSH_DIR:/home/${USER:-root}/.ssh:ro") + RUN_FLAGS+=(-v "$SSH_DIR:/tmp/composer-home/.ssh:ro") fi if [[ -n "${HOME:-}" && -d "$HOME" ]]; then mkdir -p "$HOME/.cache/composer" "$HOME/.composer" 2>/dev/null || true - RUN_FLAGS+=( - -e "COMPOSER_CACHE_DIR=/tmp/composer-cache" - -v "$HOME/.cache/composer:/tmp/composer-cache" - -v "$HOME/.composer:/tmp/composer-home" - -e "COMPOSER_HOME=/tmp/composer-home" - ) + RUN_FLAGS+=(-v "$HOME/.cache/composer:/tmp/composer-cache") + RUN_FLAGS+=(-v "$HOME/.composer:/tmp/composer-home") fi exec docker run "${RUN_FLAGS[@]}" "$IMAGE" composer "${ARGS[@]}" diff --git a/bin/es b/bin/es new file mode 100755 index 0000000..dc1611b --- /dev/null +++ b/bin/es @@ -0,0 +1,434 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$SCRIPT_DIR/.." 2>/dev/null && pwd || pwd)" +ENV_FILE="$REPO_ROOT/.env" + +SERVICE="${ELASTICSEARCH_SERVICE:-ELASTICSEARCH}" +ES_SCHEME="${ELASTICSEARCH_SCHEME:-http}" +ES_HOST="${ELASTICSEARCH_HOST:-127.0.0.1}" +ES_PORT="${ELASTICSEARCH_PORT:-9200}" +ES_USER="${ELASTICSEARCH_USERNAME:-${ES_USERNAME:-}}" +ES_PASS="${ELASTICSEARCH_PASSWORD:-${ES_PASSWORD:-}}" +ES_API_KEY="${ELASTICSEARCH_API_KEY:-${ES_API_KEY:-}}" +ES_INSECURE="${ELASTICSEARCH_INSECURE:-0}" + +is_msys() { + case "${OSTYPE:-}" in + msys*|cygwin*|win32*) return 0 ;; + esac + return 1 +} + +load_env_file() { + [[ -f "$ENV_FILE" ]] || return 0 + + local key raw value + while IFS= read -r raw || [[ -n "$raw" ]]; do + raw="${raw%$'\r'}" + [[ -z "$raw" || "$raw" =~ ^[[:space:]]*# ]] && continue + raw="${raw#export }" + [[ "$raw" == *=* ]] || continue + + key="${raw%%=*}" + value="${raw#*=}" + + key="${key##[[:space:]]}" + key="${key%%[[:space:]]}" + [[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue + + if [[ "$value" =~ ^\".*\"$ || "$value" =~ ^\'.*\'$ ]]; then + value="${value:1:${#value}-2}" + fi + + case "$key" in + ELASTICSEARCH_SERVICE) [[ -z "${ELASTICSEARCH_SERVICE:-}" ]] && SERVICE="$value" ;; + ELASTICSEARCH_SCHEME) [[ -z "${ELASTICSEARCH_SCHEME:-}" ]] && ES_SCHEME="$value" ;; + ELASTICSEARCH_HOST) [[ -z "${ELASTICSEARCH_HOST:-}" ]] && ES_HOST="$value" ;; + ELASTICSEARCH_PORT) [[ -z "${ELASTICSEARCH_PORT:-}" ]] && ES_PORT="$value" ;; + ELASTICSEARCH_USERNAME|ES_USERNAME) [[ -z "$ES_USER" ]] && ES_USER="$value" ;; + ELASTICSEARCH_PASSWORD|ES_PASSWORD) [[ -z "$ES_PASS" ]] && ES_PASS="$value" ;; + ELASTICSEARCH_API_KEY|ES_API_KEY) [[ -z "$ES_API_KEY" ]] && ES_API_KEY="$value" ;; + ELASTICSEARCH_INSECURE) [[ -z "${ELASTICSEARCH_INSECURE:-}" ]] && ES_INSECURE="$value" ;; + esac + done < "$ENV_FILE" +} + +container_env() { + local name="$1" + docker inspect -f "{{range .Config.Env}}{{println .}}{{end}}" "$SERVICE" 2>/dev/null \ + | awk -F= -v key="$name" '$1==key { sub(/^[^=]*=/, ""); print; exit }' +} + +container_running() { + docker inspect -f '{{.State.Running}}' "$SERVICE" 2>/dev/null | grep -qi '^true$' +} + +require_service() { + if ! container_running; then + echo "Error: $SERVICE is not running." >&2 + exit 1 + fi +} + +resolve_container_defaults() { + local v + + v="$(container_env ELASTICSEARCH_USERNAME || true)" + [[ -n "$v" && -z "$ES_USER" ]] && ES_USER="$v" + + v="$(container_env ELASTICSEARCH_PASSWORD || true)" + [[ -n "$v" && -z "$ES_PASS" ]] && ES_PASS="$v" + + v="$(container_env ES_USERNAME || true)" + [[ -n "$v" && -z "$ES_USER" ]] && ES_USER="$v" + + v="$(container_env ES_PASSWORD || true)" + [[ -n "$v" && -z "$ES_PASS" ]] && ES_PASS="$v" + + v="$(container_env ELASTICSEARCH_API_KEY || true)" + [[ -n "$v" && -z "$ES_API_KEY" ]] && ES_API_KEY="$v" + + v="$(container_env ES_API_KEY || true)" + [[ -n "$v" && -z "$ES_API_KEY" ]] && ES_API_KEY="$v" + + v="$(container_env ELASTICSEARCH_HOST || true)" + [[ -n "$v" && -z "${ELASTICSEARCH_HOST:-}" ]] && ES_HOST="$v" + + v="$(container_env ELASTICSEARCH_PORT || true)" + [[ -n "$v" && -z "${ELASTICSEARCH_PORT:-}" ]] && ES_PORT="$v" +} + +base_url() { + printf '%s://%s:%s' "$ES_SCHEME" "$ES_HOST" "$ES_PORT" +} + +have_stdin() { + [[ ! -t 0 ]] +} + +join_by() { + local sep="$1"; shift || true + local first=1 out="" + local s + for s in "$@"; do + if (( first )); then + out="$s" + first=0 + else + out+="$sep$s" + fi + done + printf '%s' "$out" +} + +curl_exec() { + local method="$1" + local path="$2" + local data_file="${3:-}" + local content_type="${4:-application/json}" + shift $(( $# >= 4 ? 4 : $# )) || true + local extra=("$@") + + local url + if [[ "$path" =~ ^https?:// ]]; then + url="$path" + else + [[ "$path" == /* ]] || path="/$path" + url="$(base_url)$path" + fi + + local cmd=(docker exec -i "$SERVICE" curl -sS -X "$method" "$url") + (( ES_INSECURE )) && cmd+=(--insecure) + cmd+=(-H 'Accept: application/json') + + if [[ -n "$ES_API_KEY" ]]; then + cmd+=(-H "Authorization: ApiKey $ES_API_KEY") + elif [[ -n "$ES_USER" || -n "$ES_PASS" ]]; then + cmd+=(-u "${ES_USER}:${ES_PASS}") + fi + + if [[ -n "$data_file" ]]; then + cmd+=(-H "Content-Type: $content_type" --data-binary "@$data_file") + fi + + if ((${#extra[@]})); then + cmd+=("${extra[@]}") + fi + + "${cmd[@]}" +} + +mktemp_host() { + mktemp 2>/dev/null || mktemp -t es +} + +cleanup_files=() +cleanup() { + local f + for f in "${cleanup_files[@]:-}"; do + [[ -n "$f" && -f "$f" ]] && rm -f -- "$f" + done +} +trap cleanup EXIT + +prepare_body_file() { + local src="${1:-}" + local tmp + tmp="$(mktemp_host)" + cleanup_files+=("$tmp") + + if [[ -n "$src" ]]; then + if [[ "$src" == @* ]]; then + src="${src#@}" + fi + [[ -f "$src" ]] || { echo "Error: body file not found: $src" >&2; exit 1; } + cat -- "$src" > "$tmp" + else + cat > "$tmp" + fi + + printf '%s' "$tmp" +} + +print_usage() { + cat < # GET /_cat/?v + es get # GET + es delete # DELETE + es head # HEAD + es post [@file|-] # POST JSON body + es put [@file|-] # PUT JSON body + es bulk [@file|-] # POST /_bulk (ndjson) + es search [@file|-] # POST //_search + es index [id] [@file|-] # POST/PUT document + es raw [body] # Generic passthrough + +Examples: + es health + es get /_cluster/settings?pretty + es post /my-index/_search @query.json + cat bulk.ndjson | es bulk - + es index my-index @doc.json + es index my-index 42 @doc.json +USAGE +} + +main() { + load_env_file + require_service + resolve_container_defaults + + local cmd="${1:-status}" + [[ $# -gt 0 ]] && shift || true + + case "$cmd" in + -h|--help|help) + print_usage + ;; + + status) + curl_exec GET '/?pretty' + ;; + + health) + curl_exec GET '/_cluster/health?pretty' + ;; + + indices) + curl_exec GET '/_cat/indices?v' + ;; + + aliases) + curl_exec GET '/_cat/aliases?v' + ;; + + nodes) + curl_exec GET '/_cat/nodes?v' + ;; + + shards) + curl_exec GET '/_cat/shards?v' + ;; + + cat) + local path="${1:-}" + [[ -n "$path" ]] || { echo 'Error: cat requires a path.' >&2; exit 1; } + shift || true + path="${path#/}" + if [[ "$path" == _cat/* ]]; then + curl_exec GET "/${path}?v" '' '' "$@" + else + curl_exec GET "/_cat/${path}?v" '' '' "$@" + fi + ;; + + get) + local path="${1:-/}" + shift || true + curl_exec GET "$path" '' '' "$@" + ;; + + delete) + local path="${1:-}" + [[ -n "$path" ]] || { echo 'Error: delete requires a path.' >&2; exit 1; } + shift || true + curl_exec DELETE "$path" '' '' "$@" + ;; + + head) + local path="${1:-}" + [[ -n "$path" ]] || { echo 'Error: head requires a path.' >&2; exit 1; } + shift || true + curl_exec HEAD "$path" '' '' -I "$@" + ;; + + post|put) + local method="${cmd^^}" + local path="${1:-}" + [[ -n "$path" ]] || { echo "Error: $cmd requires a path." >&2; exit 1; } + shift || true + local body_arg="${1:-}" + [[ $# -gt 0 ]] && shift || true + local body_file='' + if [[ -n "$body_arg" ]]; then + if [[ "$body_arg" == '-' ]]; then + body_file="$(prepare_body_file)" + else + body_file="$(prepare_body_file "$body_arg")" + fi + elif have_stdin; then + body_file="$(prepare_body_file)" + fi + curl_exec "$method" "$path" "$body_file" 'application/json' "$@" + ;; + + bulk) + local body_arg="${1:-}" + [[ $# -gt 0 ]] && shift || true + local body_file='' + if [[ -n "$body_arg" ]]; then + if [[ "$body_arg" == '-' ]]; then + body_file="$(prepare_body_file)" + else + body_file="$(prepare_body_file "$body_arg")" + fi + elif have_stdin; then + body_file="$(prepare_body_file)" + else + echo 'Error: bulk requires @file or stdin.' >&2 + exit 1 + fi + curl_exec POST '/_bulk' "$body_file" 'application/x-ndjson' "$@" + ;; + + search) + local index="${1:-}" + [[ -n "$index" ]] || { echo 'Error: search requires an index or path.' >&2; exit 1; } + shift || true + local body_arg="${1:-}" + [[ $# -gt 0 ]] && shift || true + local body_file='' + if [[ -n "$body_arg" ]]; then + if [[ "$body_arg" == '-' ]]; then + body_file="$(prepare_body_file)" + else + body_file="$(prepare_body_file "$body_arg")" + fi + elif have_stdin; then + body_file="$(prepare_body_file)" + fi + if [[ "$index" == /* ]]; then + curl_exec POST "$index" "$body_file" 'application/json' "$@" + else + curl_exec POST "/${index}/_search" "$body_file" 'application/json' "$@" + fi + ;; + + index) + local index_name="${1:-}" + [[ -n "$index_name" ]] || { echo 'Error: index requires an index name.' >&2; exit 1; } + shift || true + local maybe_id="${1:-}" + local id='' + local body_arg='' + + if [[ -n "$maybe_id" ]]; then + case "$maybe_id" in + @*|-) + body_arg="$maybe_id" + shift || true + ;; + *) + id="$maybe_id" + shift || true + body_arg="${1:-}" + [[ $# -gt 0 ]] && shift || true + ;; + esac + fi + + local body_file='' + if [[ -n "$body_arg" ]]; then + if [[ "$body_arg" == '-' ]]; then + body_file="$(prepare_body_file)" + else + body_file="$(prepare_body_file "$body_arg")" + fi + elif have_stdin; then + body_file="$(prepare_body_file)" + else + echo 'Error: index requires a document body via @file or stdin.' >&2 + exit 1 + fi + + if [[ -n "$id" ]]; then + curl_exec PUT "/${index_name}/_doc/${id}" "$body_file" 'application/json' "$@" + else + curl_exec POST "/${index_name}/_doc" "$body_file" 'application/json' "$@" + fi + ;; + + raw) + local method="${1:-}" + local path="${2:-}" + [[ -n "$method" && -n "$path" ]] || { echo 'Error: raw requires METHOD and path.' >&2; exit 1; } + shift 2 || true + local body_arg="${1:-}" + [[ $# -gt 0 ]] && shift || true + local body_file='' + if [[ -n "$body_arg" ]]; then + if [[ "$body_arg" == '-' ]]; then + body_file="$(prepare_body_file)" + else + body_file="$(prepare_body_file "$body_arg")" + fi + elif have_stdin && [[ ! "$method" =~ ^(GET|HEAD|DELETE|get|head|delete)$ ]]; then + body_file="$(prepare_body_file)" + fi + curl_exec "${method^^}" "$path" "$body_file" 'application/json' "$@" + ;; + + *) + if [[ "$cmd" == /* || "$cmd" =~ ^_ ]]; then + curl_exec GET "$cmd" '' '' "$@" + else + echo "Error: unknown command: $cmd" >&2 + echo >&2 + print_usage >&2 + exit 1 + fi + ;; + esac +} + +main "$@" diff --git a/bin/maria b/bin/maria index 8f26cf0..b48a28e 100755 --- a/bin/maria +++ b/bin/maria @@ -56,7 +56,9 @@ SERVICE="${MARIADB_CONTAINER:-${MYSQL_CONTAINER:-MARIADB}}" MARIADB_PORT_IN="${MARIADB_PORT_IN:-${MYSQL_PORT_IN:-}}" require_container() { - docker inspect "$SERVICE" >/dev/null 2>&1 || die "container '$SERVICE' is not running." + local running + running="$(docker inspect -f '{{.State.Running}}' "$SERVICE" 2>/dev/null || true)" + [[ "$running" == "true" ]] || die "container '$SERVICE' is not running." } is_tty() { [[ -t 0 ]] && echo "-it" || echo "-i"; } @@ -93,12 +95,25 @@ infer_port() { # Host mount (keep it simple & reliable like your working mysql script): # always mount *current directory* for file ops. +resolve_host_pwd() { + local wd="${WORKDIR_WIN:-${WORKING_DIR:-$PWD}}" + + if is_msys && [[ "$wd" == *"Program Files/Git/workspace"* ]]; then + wd="$PWD" + fi + + printf "%s" "$wd" +} + mount_host_pwd() { + local wd + wd="$(resolve_host_pwd)" + msys_no_pathconv if is_msys && command -v cygpath >/dev/null 2>&1; then - cygpath -w "$PWD" + cygpath -w "$wd" else - printf "%s" "$PWD" + printf "%s" "$wd" fi } @@ -108,7 +123,7 @@ to_workspace_path() { [[ -f "$host_path" ]] || die "file not found: $host_path" local abs cwd rel - cwd="$(cd "$PWD" && pwd)" + cwd="$(cd "$(resolve_host_pwd)" && pwd)" abs="$(cd "$(dirname "$host_path")" && pwd)/$(basename "$host_path")" if command -v python3 >/dev/null 2>&1; then @@ -364,6 +379,7 @@ EOF shift local db="${1:-$DEF_DB}" [[ -n "$db" ]] || die "tables requires a db name (or set MARIADB_DATABASE/MYSQL_DATABASE)." + [[ "$db" =~ ^[A-Za-z0-9_]+$ ]] || die "invalid db name '$db' (allowed: A-Z a-z 0-9 _)" # across schemas (but scoped to selected DB unless you want global — keep predictable): if [[ "$as_user" == true ]]; then db_exec_user "$client" "$db" -e \ diff --git a/bin/mongo b/bin/mongo new file mode 100755 index 0000000..072299c --- /dev/null +++ b/bin/mongo @@ -0,0 +1,381 @@ +#!/usr/bin/env bash +set -euo pipefail + +SERVICE_DEFAULT="MONGODB" +WORKSPACE_MOUNT="/workspace" + +trim() { + local s="${1-}" + s="${s#${s%%[![:space:]]*}}" + s="${s%${s##*[![:space:]]}}" + printf '%s' "$s" +} + +err() { + echo "[mongo] $*" >&2 + exit 1 +} + +warn() { + echo "[mongo] $*" >&2 +} + +is_msys() { + case "$(uname -s 2>/dev/null || true)" in + MINGW*|MSYS*|CYGWIN*) return 0 ;; + *) return 1 ;; + esac +} + +repo_root() { + git rev-parse --show-toplevel 2>/dev/null || pwd +} + +load_env() { + local env_file="${LDS_ENV_FILE:-.env}" + [[ -f "$env_file" ]] || return 0 + + local line key val + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line%$'\r'}" + [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]] && continue + [[ "$line" == export* ]] && line="${line#export }" + [[ "$line" == *=* ]] || continue + + key="${line%%=*}" + val="${line#*=}" + key="$(trim "$key")" + val="$(trim "$val")" + + [[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue + case "$key" in + MONGODB_SERVICE|MONGODB_ROOT_USERNAME|MONGODB_ROOT_PASSWORD|MONGO_INITDB_ROOT_USERNAME|MONGO_INITDB_ROOT_PASSWORD|WORKING_DIR|WORKDIR|WORKDIR_WIN) + ;; + *) + continue + ;; + esac + + if [[ "$val" == \"*\" && "$val" == *\" ]]; then + val="${val:1:${#val}-2}" + elif [[ "$val" == \'*\' && "$val" == *\' ]]; then + val="${val:1:${#val}-2}" + fi + + export "$key=$val" + done < "$env_file" +} + +container_running() { + local service="$1" + [[ -n "$service" ]] || return 1 + [[ "$(docker inspect -f '{{.State.Running}}' "$service" 2>/dev/null || true)" == "true" ]] +} + +container_env() { + local service="$1" key="$2" + docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$service" 2>/dev/null \ + | awk -F= -v k="$key" '$1==k {sub($1"=", ""); print; exit}' +} + +service_name() { + printf '%s' "${MONGODB_SERVICE:-$SERVICE_DEFAULT}" +} + +mongo_user() { + local service="$1" + local v + for v in \ + "${MONGODB_ROOT_USERNAME:-}" \ + "${MONGO_INITDB_ROOT_USERNAME:-}" \ + "$(container_env "$service" MONGO_INITDB_ROOT_USERNAME)"; do + [[ -n "$v" ]] && { printf '%s' "$v"; return 0; } + done + printf 'root' +} + +mongo_pass() { + local service="$1" + local v + for v in \ + "${MONGODB_ROOT_PASSWORD:-}" \ + "${MONGO_INITDB_ROOT_PASSWORD:-}" \ + "$(container_env "$service" MONGO_INITDB_ROOT_PASSWORD)"; do + [[ -n "$v" ]] && { printf '%s' "$v"; return 0; } + done + printf '12345' +} + +mongo_image() { + docker inspect -f '{{.Config.Image}}' "$1" 2>/dev/null +} + +workspace_host() { + local wd="${WORKDIR_WIN:-${WORKING_DIR:-${WORKDIR:-$PWD}}}" + + if is_msys; then + if [[ "$wd" == *"/Program Files/Git/workspace"* ]] || [[ "$wd" == "/workspace" ]]; then + wd="$(repo_root)" + fi + if command -v cygpath >/dev/null 2>&1; then + cygpath -aw "$wd" + return 0 + fi + fi + + printf '%s' "$wd" +} + +inside_workspace_path() { + local input="$1" + local host_root + host_root="$(workspace_host)" + + [[ -n "$input" ]] || err "missing file path" + + if [[ "$input" != /* ]]; then + input="$PWD/$input" + fi + input="$(cd "$(dirname "$input")" 2>/dev/null && pwd)/$(basename "$input")" + + local host_root_norm="$host_root" + if is_msys && command -v cygpath >/dev/null 2>&1; then + host_root_norm="$(cygpath -am "$host_root")" + input="$(cygpath -am "$input")" + fi + + case "$input" in + "$host_root_norm"/*) + printf '%s/%s' "$WORKSPACE_MOUNT" "${input#"$host_root_norm"/}" + ;; + "$host_root_norm") + printf '%s' "$WORKSPACE_MOUNT" + ;; + *) + err "file must be under workspace: $host_root_norm" + ;; + esac +} + +common_uri() { + local service="$1" + local user pass + user="$(mongo_user "$service")" + pass="$(mongo_pass "$service")" + printf 'mongodb://%s:%s@127.0.0.1:27017/admin?authSource=admin' "$user" "$pass" +} + +mongosh_args() { + local service="$1" db="${2:-admin}" + local user pass + user="$(mongo_user "$service")" + pass="$(mongo_pass "$service")" + printf '%s\n' --host 127.0.0.1 --port 27017 --username "$user" --password "$pass" --authenticationDatabase admin "$db" +} + +run_mongosh() { + local service="$1"; shift + local -a args=( "$@" ) + docker exec -it "$service" mongosh "${args[@]}" +} + +run_mongosh_non_tty() { + local service="$1"; shift + local -a args=( "$@" ) + docker exec -i "$service" mongosh "${args[@]}" +} + +run_helper() { + local service="$1"; shift + local image + image="$(mongo_image "$service")" + [[ -n "$image" ]] || err "unable to inspect image for $service" + + local host_ws + host_ws="$(workspace_host)" + + local -a cmd=( docker run --rm ) + if is_msys; then + cmd+=( -e MSYS_NO_PATHCONV=1 -e MSYS2_ARG_CONV_EXCL='*' ) + fi + cmd+=( + --network "container:$service" + -v "$host_ws:$WORKSPACE_MOUNT" + -w "$WORKSPACE_MOUNT" + "$image" + "$@" + ) + "${cmd[@]}" +} + +require_db_name() { + local db="$1" + [[ -n "$db" ]] || err "database name is required" + [[ "$db" =~ ^[A-Za-z0-9._-]+$ ]] || err "invalid database name: $db" +} + +require_collection_name() { + local coll="$1" + [[ -n "$coll" ]] || err "collection name is required" + [[ "$coll" =~ ^[A-Za-z0-9._-]+$ ]] || err "invalid collection name: $coll" +} + +print_help() { + cat <<'HELP' +Usage: + mongo Open mongosh (admin) + mongo shell [db] Open mongosh for a database + mongo status Ping server + mongo dbs List databases + mongo collections [db] List collections + mongo eval [db] Execute JavaScript + mongo create-db Create database lazily via temp collection + mongo drop-db Drop database + mongo import [--drop] [--type json|jsonl|csv] + mongo export [file] [--jsonArray] + +Notes: + - File paths for import/export must be inside the current workspace. + - Import/export run in a one-off helper container from the same Mongo image. +HELP +} + +main() { + load_env + + local service + service="$(service_name)" + container_running "$service" || err "$service is not running" + + local cmd="${1:-shell}" + if [[ $# -gt 0 ]]; then shift; fi + + case "$cmd" in + help|-h|--help) + print_help + ;; + shell) + local db="${1:-admin}" + run_mongosh "$service" $(mongosh_args "$service" "$db") + ;; + status) + run_mongosh_non_tty "$service" $(mongosh_args "$service" admin) --quiet --eval 'db.adminCommand({ ping: 1 })' + ;; + dbs) + run_mongosh_non_tty "$service" $(mongosh_args "$service" admin) --quiet --eval 'db.adminCommand({ listDatabases: 1 }).databases.forEach(d => print(d.name))' + ;; + collections) + local db="${1:-admin}" + run_mongosh_non_tty "$service" $(mongosh_args "$service" "$db") --quiet --eval 'db.getCollectionNames().forEach(c => print(c))' + ;; + eval) + local js="${1:-}" + local db="${2:-admin}" + [[ -n "$js" ]] || err "JavaScript expression is required" + run_mongosh_non_tty "$service" $(mongosh_args "$service" "$db") --quiet --eval "$js" + ;; + create-db) + local db="${1:-}" + require_db_name "$db" + run_mongosh_non_tty "$service" $(mongosh_args "$service" "$db") --quiet --eval 'db.__lds_init.insertOne({createdAt:new Date()}); db.__lds_init.deleteMany({}); print(db.getName())' + ;; + drop-db) + local db="${1:-}" + require_db_name "$db" + run_mongosh_non_tty "$service" $(mongosh_args "$service" "$db") --quiet --eval 'db.dropDatabase()' + ;; + import) + local db="${1:-}" coll="${2:-}" src="${3:-}" + shift $(( $# >= 3 ? 3 : $# )) || true + require_db_name "$db" + require_collection_name "$coll" + [[ -n "$src" ]] || err "import file is required" + [[ -f "$src" ]] || err "import file not found: $src" + + local path type ext + path="$(inside_workspace_path "$src")" + type="json" + ext="${src##*.}" + case "$ext" in + csv|CSV) type="csv" ;; + jsonl|ndjson|JSONL|NDJSON) type="json" ;; + json|JSON) type="json" ;; + esac + + local -a extra=() + while [[ $# -gt 0 ]]; do + case "$1" in + --type) + shift + [[ $# -gt 0 ]] || err "--type requires a value" + type="$1" + ;; + --drop) + extra+=( "$1" ) + ;; + *) + extra+=( "$1" ) + ;; + esac + shift || true + done + + if [[ "$type" == "json" ]]; then + case "$ext" in + jsonl|ndjson|JSONL|NDJSON) extra+=( --jsonArray=false ) ;; + json|JSON) : ;; + esac + fi + + run_helper "$service" mongoimport \ + --uri "$(common_uri "$service")" \ + --db "$db" \ + --collection "$coll" \ + --file "$path" \ + --type "$type" \ + "${extra[@]}" + ;; + export) + local db="${1:-}" coll="${2:-}" out="${3:-}" + shift $(( $# >= 2 ? 2 : $# )) || true + require_db_name "$db" + require_collection_name "$coll" + + if [[ -n "$out" && "$out" != --* ]]; then + shift || true + local host_out dir_in_ws file_in_ws + host_out="$out" + if [[ "$host_out" != /* ]]; then + host_out="$PWD/$host_out" + fi + mkdir -p "$(dirname "$host_out")" + dir_in_ws="$(inside_workspace_path "$(dirname "$host_out")")" + file_in_ws="$dir_in_ws/$(basename "$host_out")" + + run_helper "$service" mongoexport \ + --uri "$(common_uri "$service")" \ + --db "$db" \ + --collection "$coll" \ + --out "$file_in_ws" \ + "$@" + else + run_helper "$service" mongoexport \ + --uri "$(common_uri "$service")" \ + --db "$db" \ + --collection "$coll" \ + --out - \ + "$@" + fi + ;; + *) + # convenience: `mongo mydb` opens shell for db + if [[ $# -eq 0 && "$cmd" =~ ^[A-Za-z0-9._-]+$ ]]; then + run_mongosh "$service" $(mongosh_args "$service" "$cmd") + else + err "unknown command: $cmd" + fi + ;; + esac +} + +main "$@" diff --git a/bin/my b/bin/my index c775923..40866be 100755 --- a/bin/my +++ b/bin/my @@ -36,8 +36,8 @@ if [[ -r "$ENV_FILE" ]]; then [[ -z "${k:-}" || "$k" =~ ^[[:space:]]*# ]] && continue k="${k//[[:space:]]/}" case "$k" in - MYSQL_CONTAINER|MYSQL_PORT_IN) ;; - *) continue ;; + MYSQL_CONTAINER|MYSQL_PORT_IN|WORKDIR|WORKDIR_WIN) ;; + *) continue ;; esac v="${v%$'\r'}" v="${v#\"}"; v="${v%\"}" @@ -50,10 +50,12 @@ SERVICE="${MYSQL_CONTAINER:-MYSQL}" MYSQL_PORT_IN="${MYSQL_PORT_IN:-}" require_container() { - docker inspect "$SERVICE" >/dev/null 2>&1 || die "container '$SERVICE' is not running." + local state + state="$(docker inspect -f '{{.State.Status}}' "$SERVICE" 2>/dev/null || true)" + [[ "$state" == "running" ]] || die "container '$SERVICE' is not running." } -is_tty(){ [[ -t 0 ]] && echo "-it" || echo "-i"; } +is_tty(){ [[ -t 0 && -t 1 ]] && echo "-it" || echo "-i"; } # POSIX-safe env read from container (works in sh/ash/dash) cenv() { @@ -76,12 +78,11 @@ infer_port() { MYSQL_PORT_IN="${MYSQL_PORT_IN:-3306}" } -# Run mysql inside the container using a temp defaults file (no password in argv) mysql_exec_root() { local flags; flags="$(is_tty)" exec docker exec $flags "$SERVICE" sh -lc ' umask 077 - f="$(mktemp)"; trap "rm -f $f" EXIT + f="$(mktemp)"; trap "rm -f \"$f\"" EXIT cat >"$f" <"$f" <"$f" <"$f" <"$f" <"$f" <"$f" < [db] - NAME shorthand: uses ./NAME.sql or ./NAME.sql.gz if exists; db defaults to NAME -Export/Dump (writes local files; uses mount): +Export/Dump (writes local files): my dump [db] [out.sql|out.sql.gz] my export [db] [out.sql|out.sql.gz] # alias of dump Convenience: my "SELECT 1" -> runs on default DB my mydb "SELECT 1" -> runs on mydb - cat file.sql | mysql -> runs on default DB (stdin) + cat file.sql | my -> runs on default DB Notes: - tables shows all tables across schemas: information_schema.tables @@ -209,50 +279,22 @@ main() { shift fi - # Prefer container env (these are your defaults) local ROOT_PW USER USER_PW DEF_DB ROOT_PW="$(cenv_any MYSQL_ROOT_PASSWORD || true)" USER="$(cenv_any MYSQL_USER || true)" USER_PW="$(cenv_any MYSQL_PASSWORD || true)" DEF_DB="$(cenv_any MYSQL_DATABASE || true)" - # Stdin pipe mode: cat file.sql | my if [[ $# -eq 0 && ! -t 0 ]]; then [[ -n "${DEF_DB:-}" ]] || die "No default database set in container (MYSQL_DATABASE)." if [[ "$as_user" == true ]]; then - exec docker exec -i "$SERVICE" sh -lc ' - db="$1" - umask 077 - f="$(mktemp)"; trap "rm -f $f" EXIT - cat >"$f" <"$f" < interactive mysql shell if [[ $# -eq 0 ]]; then if [[ "$as_user" == true ]]; then mysql_exec_user; else mysql_exec_root; fi exit 0 @@ -260,53 +302,51 @@ EOF local cmd="${1:-}" case "$cmd" in - status) - docker ps --filter "name=^/${SERVICE}$" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" - exit 0 - ;; + status) + docker ps --filter "name=^/${SERVICE}$" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" + exit 0 + ;; esac case "$cmd" in - dbs) - if [[ "$as_user" == true ]]; then - mysql_exec_user -e "SHOW DATABASES;" - else - mysql_exec_root -e "SHOW DATABASES;" - fi - ;; - - tables) - shift - local db="${1:-$DEF_DB}" - - if [[ -n "${db:-}" ]]; then - # Show tables for a specific db (includes views) + dbs) if [[ "$as_user" == true ]]; then - mysql_exec_user -N -e "SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE FROM information_schema.tables WHERE TABLE_SCHEMA='${db}' ORDER BY TABLE_NAME;" + mysql_exec_user -e "SHOW DATABASES;" else - mysql_exec_root -N -e "SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE FROM information_schema.tables WHERE TABLE_SCHEMA='${db}' ORDER BY TABLE_NAME;" + mysql_exec_root -e "SHOW DATABASES;" fi - else - # All schemas (avoid "no tables" confusion) - if [[ "$as_user" == true ]]; then - mysql_exec_user -N -e "SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE FROM information_schema.tables WHERE TABLE_SCHEMA NOT IN ('mysql','sys','performance_schema','information_schema') ORDER BY TABLE_SCHEMA, TABLE_NAME;" + ;; + + tables) + shift + local db="${1:-$DEF_DB}" + if [[ -n "${db:-}" ]]; then + [[ "$db" =~ ^[A-Za-z0-9_]+$ ]] || die "invalid db name '$db' (allowed: A-Z a-z 0-9 _)" + if [[ "$as_user" == true ]]; then + mysql_exec_user -N -e "SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE FROM information_schema.tables WHERE TABLE_SCHEMA='${db}' ORDER BY TABLE_NAME;" + else + mysql_exec_root -N -e "SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE FROM information_schema.tables WHERE TABLE_SCHEMA='${db}' ORDER BY TABLE_NAME;" + fi else - mysql_exec_root -N -e "SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE FROM information_schema.tables WHERE TABLE_SCHEMA NOT IN ('mysql','sys','performance_schema','information_schema') ORDER BY TABLE_SCHEMA, TABLE_NAME;" + if [[ "$as_user" == true ]]; then + mysql_exec_user -N -e "SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE FROM information_schema.tables WHERE TABLE_SCHEMA NOT IN ('mysql','sys','performance_schema','information_schema') ORDER BY TABLE_SCHEMA, TABLE_NAME;" + else + mysql_exec_root -N -e "SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE FROM information_schema.tables WHERE TABLE_SCHEMA NOT IN ('mysql','sys','performance_schema','information_schema') ORDER BY TABLE_SCHEMA, TABLE_NAME;" + fi fi - fi - ;; + ;; - create-db) - shift - local db="${1:-$DEF_DB}" - [[ -n "${db:-}" ]] || die "create-db requires a db name (or set MYSQL_DATABASE)." - [[ "$db" =~ ^[A-Za-z0-9_]+$ ]] || die "invalid db name '$db' (allowed: A-Z a-z 0-9 _)" + create-db) + shift + local db="${1:-$DEF_DB}" + [[ -n "${db:-}" ]] || die "create-db requires a db name (or set MYSQL_DATABASE)." + [[ "$db" =~ ^[A-Za-z0-9_]+$ ]] || die "invalid db name '$db' (allowed: A-Z a-z 0-9 _)" - docker exec -i "$SERVICE" sh -lc ' + docker exec -i "$SERVICE" sh -lc ' db="$1" u="${MYSQL_USER-}" umask 077 - f="$(mktemp)"; trap "rm -f $f" EXIT + f="$(mktemp)"; trap "rm -f \"$f\"" EXIT cat >"$f" <&2 - ;; + echo "OK: database '$db' ensured${USER:+ + privileges granted to $USER}" >&2 + ;; - drop-db) - shift - local db="${1:-}" - [[ -n "${db:-}" ]] || die "drop-db requires a db name." - [[ "$db" =~ ^[A-Za-z0-9_]+$ ]] || die "invalid db name '$db' (allowed: A-Z a-z 0-9 _)" + drop-db) + shift + local db="${1:-}" + [[ -n "${db:-}" ]] || die "drop-db requires a db name." + [[ "$db" =~ ^[A-Za-z0-9_]+$ ]] || die "invalid db name '$db' (allowed: A-Z a-z 0-9 _)" - docker exec -i "$SERVICE" sh -lc ' + docker exec -i "$SERVICE" sh -lc ' db="$1" umask 077 - f="$(mktemp)"; trap "rm -f $f" EXIT + f="$(mktemp)"; trap "rm -f \"$f\"" EXIT cat >"$f" <&2 - ;; + echo "OK: dropped database '$db' (if existed)" >&2 + ;; - import) - shift - local in="${1:-}"; shift || true - [[ -n "${in:-}" ]] || die "import requires ." - - local file - if ! file="$(resolve_import_path "$in")"; then - die "file not found: $in (also tried ./${in}.sql and ./${in}.sql.gz)" - fi + import) + shift + local in="${1:-}"; shift || true + [[ -n "${in:-}" ]] || die "import requires ." - local db="${1:-}" - if [[ -z "${db:-}" ]]; then - # If shorthand NAME was used, default DB = NAME - if [[ "$in" != *.* ]]; then db="$in"; else db="${DEF_DB:-}"; fi - fi - [[ -n "${db:-}" ]] || die "import requires a db name (or set MYSQL_DATABASE)." - [[ "$db" =~ ^[A-Za-z0-9_]+$ ]] || die "invalid db name '$db' (allowed: A-Z a-z 0-9 _)" + local file + if ! file="$(resolve_import_path "$in")"; then + die "file not found: $in (also tried ./${in}.sql and ./${in}.sql.gz)" + fi - if [[ "$file" == *.gz ]]; then - if command -v gunzip >/dev/null 2>&1; then - gunzip -c "$file" | docker exec -i "$SERVICE" sh -lc ' - db="$1" - umask 077 - f="$(mktemp)"; trap "rm -f $f" EXIT - cat >"$f" </dev/null 2>&1; then + if [[ "$as_user" == true ]]; then + gunzip -c "$file" | mysql_import_stream_user "$db" + else + gunzip -c "$file" | mysql_import_stream_root "$db" + fi + else + die "gunzip not found on host; cannot import .gz" + fi else - die "gunzip not found on host; cannot import .gz" + if [[ "$as_user" == true ]]; then + cat "$file" | mysql_import_stream_user "$db" + else + cat "$file" | mysql_import_stream_root "$db" + fi fi - else - cat "$file" | docker exec -i "$SERVICE" sh -lc ' - db="$1" - umask 077 - f="$(mktemp)"; trap "rm -f $f" EXIT - cat >"$f" < db '$db'" >&2 - ;; + echo "OK: imported '$file' -> db '$db'" >&2 + ;; - export|dump) - shift - local db="${1:-$DEF_DB}" - [[ -n "${db:-}" ]] || die "dump requires a db name (or set MYSQL_DATABASE)." - [[ "$db" =~ ^[A-Za-z0-9_]+$ ]] || die "invalid db name '$db' (allowed: A-Z a-z 0-9 _)" + export|dump) + shift + local db="${1:-$DEF_DB}" + [[ -n "${db:-}" ]] || die "dump requires a db name (or set MYSQL_DATABASE)." + [[ "$db" =~ ^[A-Za-z0-9_]+$ ]] || die "invalid db name '$db' (allowed: A-Z a-z 0-9 _)" - local out="${2:-${db}.sql}" - # Write on HOST (fixes /workspace permission + uid mapping issues) - umask 077 + local out="${2:-${db}.sql}" + umask 077 - if [[ "$out" == *.gz ]]; then - if command -v gzip >/dev/null 2>&1; then - mysqldump_stdout_root "$db" | gzip -c > "$out" - else - # fallback gzip inside container - docker exec -i "$SERVICE" sh -lc ' + if [[ "$out" == *.gz ]]; then + if command -v gzip >/dev/null 2>&1; then + mysqldump_stdout_root "$db" | gzip -c > "$out" + else + docker exec -i "$SERVICE" sh -lc ' umask 077 - f="$(mktemp)"; trap "rm -f $f" EXIT + f="$(mktemp)"; trap "rm -f \"$f\"" EXIT cat >"$f" < "$out" + fi + else + mysqldump_stdout_root "$db" > "$out" fi - else - mysqldump_stdout_root "$db" > "$out" - fi - - echo "OK: dumped '$db' -> ./$out" >&2 - ;; - *) - shift 0 + echo "OK: dumped '$db' -> ./$out" >&2 + ;; - # explicit -e passthrough - if has_execute_flag "$@"; then - if [[ "$as_user" == true ]]; then mysql_exec_user "$@"; else mysql_exec_root "$@"; fi - exit 0 - fi + *) + if has_execute_flag "$@"; then + if [[ "$as_user" == true ]]; then mysql_exec_user "$@"; else mysql_exec_root "$@"; fi + exit 0 + fi - # Convenience: my "SELECT 1" - if [[ $# -eq 1 ]]; then - if looks_like_sql "$1"; then + if [[ $# -eq 1 ]] && looks_like_sql "$1"; then [[ -n "${DEF_DB:-}" ]] || die "No default database set (MYSQL_DATABASE). Provide db: my \"SQL...\"" if [[ "$as_user" == true ]]; then - exec docker exec "$(is_tty)" "$SERVICE" sh -lc ' - db="$1"; sql="$2" - umask 077 - f="$(mktemp)"; trap "rm -f $f" EXIT - cat >"$f" <"$f" <"$f" <"$f" </dev/null 2>&1; then - posix_root="$(cygpath -u "$WORKDIR_WIN" 2>/dev/null || true)" - fi + if [[ -z "$posix_root" && -n "${WORKDIR_WIN:-}" ]] && command -v cygpath >/dev/null 2>&1; then + posix_root="$(cygpath -u "$WORKDIR_WIN" 2>/dev/null || true)" fi if [[ -z "$posix_root" && -n "${WORKING_DIR:-}" ]]; then if [[ -d "$WORKING_DIR" ]]; then posix_root="$WORKING_DIR" - else - if command -v cygpath >/dev/null 2>&1; then - posix_root="$(cygpath -u "$WORKING_DIR" 2>/dev/null || true)" - fi + elif command -v cygpath >/dev/null 2>&1; then + posix_root="$(cygpath -u "$WORKING_DIR" 2>/dev/null || true)" fi fi [[ -n "$posix_root" && -d "$posix_root" ]] || posix_root="$PWD" posix_root="$(cd "$posix_root" && pwd)" + # Git Bash can sometimes resolve to a synthetic workspace under Program Files/Git. + # Prefer the repository root in that case to avoid invalid Docker Desktop mounts. + if is_msys && [[ "$posix_root" == *"/Program Files/Git/workspace"* || "$posix_root" == *"/Program Files/Git/workspace/"* ]]; then + posix_root="$ROOT_DIR" + fi + if is_msys && command -v cygpath >/dev/null 2>&1; then host_path="$(cygpath -w "$posix_root")" else @@ -79,21 +81,27 @@ resolve_mount_root() { # ───────────────────────────────────────────────────────────────────────────── # Optional host .env load (whitelist only; optional fallback) # ───────────────────────────────────────────────────────────────────────────── +load_env_value() { + local raw="${1:-}" + raw="${raw%$'\r'}" + raw="${raw#\"}" + raw="${raw%\"}" + raw="${raw#\'}" + raw="${raw%\'}" + printf '%s' "$raw" +} + if [[ -r "$ENV_FILE" ]]; then while IFS='=' read -r k v; do [[ -z "${k:-}" || "$k" =~ ^[[:space:]]*# ]] && continue k="${k//[[:space:]]/}" case "$k" in - POSTGRESQL_CONTAINER | POSTGRES_CONTAINER | POSTGRES_PORT_IN | WORKING_DIR) ;; - *) continue ;; + POSTGRESQL_CONTAINER|POSTGRES_CONTAINER|POSTGRES_PORT_IN|WORKING_DIR|WORKDIR|WORKDIR_WIN) ;; + *) continue ;; esac - v="${v%$'\r'}" - v="${v#\"}" - v="${v%\"}" - v="${v#\'}" - v="${v%\'}" + v="$(load_env_value "${v:-}")" export "$k=$v" - done <"$ENV_FILE" + done < "$ENV_FILE" fi SERVICE="${POSTGRESQL_CONTAINER:-${POSTGRES_CONTAINER:-POSTGRESQL}}" @@ -223,14 +231,29 @@ NOTES: TXT } -case "${1:-}" in help | -h | --help) - usage - exit 0 - ;; +case "${1:-}" in + help|-h|--help) + usage + exit 0 + ;; esac -require_container() { docker inspect "$SERVICE" >/dev/null 2>&1 || die "container '$SERVICE' is not running."; } -is_tty() { [[ -t 0 ]] && echo "-it" || echo "-i"; } +require_container() { + docker inspect "$SERVICE" >/dev/null 2>&1 || die "container '$SERVICE' does not exist." + local running + running="$(docker inspect -f '{{.State.Running}}' "$SERVICE" 2>/dev/null || true)" + [[ "$running" == "true" ]] || die "container '$SERVICE' is not running." +} + +is_tty() { [[ -t 0 && -t 1 ]]; } + +docker_tty_flags() { + if is_tty; then + printf '%s\n' '-it' + else + printf '%s\n' '-i' + fi +} # POSIX-safe env read from container cenv() { @@ -242,10 +265,10 @@ cenv_any() { local v key for key in "$@"; do v="$(cenv "$key")" - [[ -n "${v:-}" ]] && { - printf "%s" "$v" + if [[ -n "${v:-}" ]]; then + printf '%s' "$v" return 0 - } + fi done return 1 } @@ -256,13 +279,19 @@ infer_port() { POSTGRES_PORT_IN="${POSTGRES_PORT_IN:-5432}" } -pg_image() { docker inspect -f '{{.Config.Image}}' "$SERVICE" 2>/dev/null || die "failed to inspect image for '$SERVICE'."; } +pg_image() { + docker inspect -f '{{.Config.Image}}' "$SERVICE" 2>/dev/null || die "failed to inspect image for '$SERVICE'." +} run_pg_mount() { local image="$1" shift local flags=() - [[ -t 0 ]] && flags+=(-it) || flags+=(-i) + if is_tty; then + flags=(-it) + else + flags=(-i) + fi msys_no_pathconv docker run --rm "${flags[@]}" \ -v "$MOUNT_HOST_PATH":/workspace -w /workspace \ @@ -271,43 +300,58 @@ run_pg_mount() { } # file -> /workspace/ if under mount root; else empty +file_abs_path() { + local file="$1" + cd "$(dirname "$file")" && printf '%s/%s' "$PWD" "$(basename "$file")" +} + to_workspace_path_under_mount() { local file="$1" [[ -f "$file" ]] || return 1 local abs rel="" - abs="$(cd "$(dirname "$file")" && pwd)/$(basename "$file")" + abs="$(file_abs_path "$file")" if command -v python3 >/dev/null 2>&1; then - rel="$( - python3 - "$MOUNT_POSIX_ROOT" "$abs" <<'PY' 2>/dev/null || true + rel="$(python3 - "$MOUNT_POSIX_ROOT" "$abs" <<'PY' 2>/dev/null || true import os,sys -root=sys.argv[1]; p=sys.argv[2] +root=sys.argv[1] +p=sys.argv[2] try: - r=os.path.relpath(p,root) - if r and not r.startswith("..") and not os.path.isabs(r): - print(r) + r=os.path.relpath(p, root) + if r and not r.startswith('..') and not os.path.isabs(r): + print(r) except Exception: - pass + pass PY - )" +)" else case "$abs/" in - "$MOUNT_POSIX_ROOT"/*) rel="${abs#"$MOUNT_POSIX_ROOT/"}" ;; - *) rel="" ;; + "$MOUNT_POSIX_ROOT"/*) rel="${abs#"$MOUNT_POSIX_ROOT/"}" ;; + *) rel="" ;; esac fi [[ -n "$rel" ]] || return 1 - printf "/workspace/%s" "$rel" + printf '/workspace/%s' "$rel" } psql_exec() { - local flags - flags="$(is_tty)" local db="$1" user="$2" pass="$3" shift 3 - exec docker exec $flags -e "PGPASSWORD=$pass" "$SERVICE" \ - psql -h127.0.0.1 -p"$POSTGRES_PORT_IN" -U"$user" ${db:+-d "$db"} "$@" + + local docker_flags=() + if is_tty; then + docker_flags=(-it) + else + docker_flags=(-i) + fi + + local psql_args=(-h127.0.0.1 -p"$POSTGRES_PORT_IN" -U"$user") + [[ -n "$db" ]] && psql_args+=(-d "$db") + psql_args+=("$@") + + exec docker exec "${docker_flags[@]}" -e "PGPASSWORD=$pass" "$SERVICE" \ + psql "${psql_args[@]}" } is_safe_ident() { [[ "$1" =~ ^[A-Za-z0-9_]+$ ]]; } @@ -356,7 +400,7 @@ main() { resolve_mount_root msys_no_pathconv - # Your container env names: + # Container env names: # POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DATABASE local PGUSER PGPASS PGDB IMAGE PGUSER="$(cenv_any POSTGRES_USER PGUSER || true)" @@ -380,243 +424,241 @@ main() { fi case "${1:-}" in - status) - docker ps --filter "name=^/${SERVICE}$" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" - exit 0 - ;; + status) + docker ps -a --filter "name=^/${SERVICE}$" --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' + exit 0 + ;; esac local cmd="${1:-}" case "$cmd" in - dbs) - psql_exec "${PGDB:-postgres}" "$PGUSER" "$PGPASS" -v ON_ERROR_STOP=1 -c '\l' - ;; - - tables) - shift - local db="${1:-$PGDB}" - [[ -n "$db" ]] || die "tables requires a db name (or set POSTGRES_DATABASE)." - psql_exec "$db" "$PGUSER" "$PGPASS" -v ON_ERROR_STOP=1 -c '\dt *.*' - ;; - - create-db) - shift - local db="${1:-$PGDB}" - [[ -n "$db" ]] || die "create-db requires a db name." - ensure_db "$db" "$PGUSER" "$PGPASS" - ;; - - drop-db) - shift - local db="${1:-}" - [[ -n "$db" ]] || die "drop-db requires a db name." - drop_db "$db" "$PGUSER" "$PGPASS" - ;; - - import) - shift - local in="${1:-}" - shift || true - [[ -n "$in" ]] || die "import requires ." - - local file="$in" - local db="${1:-}" - - # shorthand: "import reporting_db" -> ./reporting_db.sql(.gz), db defaults to reporting_db - if [[ ! -f "$file" ]]; then - if [[ -f "./${in}.sql" ]]; then - file="./${in}.sql" - [[ -n "${db:-}" ]] || db="$in" - elif [[ -f "./${in}.sql.gz" ]]; then - file="./${in}.sql.gz" - [[ -n "${db:-}" ]] || db="$in" + dbs) + psql_exec "${PGDB:-postgres}" "$PGUSER" "$PGPASS" -v ON_ERROR_STOP=1 -c '\l' + ;; + + tables) + shift + local db="${1:-$PGDB}" + [[ -n "$db" ]] || die "tables requires a db name (or set POSTGRES_DATABASE)." + psql_exec "$db" "$PGUSER" "$PGPASS" -v ON_ERROR_STOP=1 -c '\dt *.*' + ;; + + create-db) + shift + local db="${1:-$PGDB}" + [[ -n "$db" ]] || die "create-db requires a db name." + ensure_db "$db" "$PGUSER" "$PGPASS" + ;; + + drop-db) + shift + local db="${1:-}" + [[ -n "$db" ]] || die "drop-db requires a db name." + drop_db "$db" "$PGUSER" "$PGPASS" + ;; + + import) + shift + local in="${1:-}" + shift || true + [[ -n "$in" ]] || die "import requires ." + + local file="$in" + local db="${1:-}" + + if [[ ! -f "$file" ]]; then + if [[ -f "./${in}.sql" ]]; then + file="./${in}.sql" + [[ -n "${db:-}" ]] || db="$in" + elif [[ -f "./${in}.sql.gz" ]]; then + file="./${in}.sql.gz" + [[ -n "${db:-}" ]] || db="$in" + fi fi - fi - [[ -f "$file" ]] || die "file not found: $file" - [[ -n "${db:-$PGDB}" ]] || die "import requires a db name (or set POSTGRES_DATABASE)." - db="${db:-$PGDB}" + [[ -f "$file" ]] || die "file not found: $file" + [[ -n "${db:-$PGDB}" ]] || die "import requires a db name (or set POSTGRES_DATABASE)." + db="${db:-$PGDB}" - local ws="" - ws="$(to_workspace_path_under_mount "$file" || true)" - if [[ -n "$ws" ]]; then - if [[ "$file" == *.gz ]]; then - run_pg_mount "$IMAGE" sh -lc ' + local ws="" + ws="$(to_workspace_path_under_mount "$file" || true)" + if [[ -n "$ws" ]]; then + if [[ "$file" == *.gz ]]; then + run_pg_mount "$IMAGE" sh -lc ' f="$1"; db="$2"; user="$3"; pass="$4"; port="$5" gunzip -c "$f" | PGPASSWORD="$pass" psql -h127.0.0.1 -p"$port" -U"$user" -d "$db" -v ON_ERROR_STOP=1 ' sh "$ws" "$db" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" - else - run_pg_mount "$IMAGE" sh -lc ' + else + run_pg_mount "$IMAGE" sh -lc ' f="$1"; db="$2"; user="$3"; pass="$4"; port="$5" PGPASSWORD="$pass" psql -h127.0.0.1 -p"$port" -U"$user" -d "$db" -v ON_ERROR_STOP=1 -f "$f" ' sh "$ws" "$db" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" - fi - else - if [[ "$file" == *.gz ]]; then - gunzip -c "$file" | docker exec -i -e "PGPASSWORD=$PGPASS" "$SERVICE" \ - psql -h127.0.0.1 -p"$POSTGRES_PORT_IN" -U"$PGUSER" -d "$db" -v ON_ERROR_STOP=1 + fi else - cat "$file" | docker exec -i -e "PGPASSWORD=$PGPASS" "$SERVICE" \ - psql -h127.0.0.1 -p"$POSTGRES_PORT_IN" -U"$PGUSER" -d "$db" -v ON_ERROR_STOP=1 + if [[ "$file" == *.gz ]]; then + gunzip -c "$file" | docker exec -i -e "PGPASSWORD=$PGPASS" "$SERVICE" \ + psql -h127.0.0.1 -p"$POSTGRES_PORT_IN" -U"$PGUSER" -d "$db" -v ON_ERROR_STOP=1 + else + cat "$file" | docker exec -i -e "PGPASSWORD=$PGPASS" "$SERVICE" \ + psql -h127.0.0.1 -p"$POSTGRES_PORT_IN" -U"$PGUSER" -d "$db" -v ON_ERROR_STOP=1 + fi fi - fi - echo "OK: imported '$file' -> db '$db'" >&2 - ;; + echo "OK: imported '$file' -> db '$db'" >&2 + ;; - dump) - shift - local db="${1:-$PGDB}" - [[ -n "$db" ]] || die "dump requires a db name (or set POSTGRES_DATABASE)." - local out="${2:-${db}.sql}" + dump) + shift + local db="${1:-$PGDB}" + [[ -n "$db" ]] || die "dump requires a db name (or set POSTGRES_DATABASE)." + local out="${2:-${db}.sql}" - if [[ "$out" == *.gz ]]; then - run_pg_mount "$IMAGE" sh -lc ' + if [[ "$out" == *.gz ]]; then + run_pg_mount "$IMAGE" sh -lc ' db="$1"; out="$2"; user="$3"; pass="$4"; port="$5" PGPASSWORD="$pass" pg_dump -h127.0.0.1 -p"$port" -U"$user" -d "$db" --no-owner --no-privileges \ | gzip -c > "/workspace/$out" ' sh "$db" "$out" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" - else - run_pg_mount "$IMAGE" sh -lc ' + else + run_pg_mount "$IMAGE" sh -lc ' db="$1"; out="$2"; user="$3"; pass="$4"; port="$5" PGPASSWORD="$pass" pg_dump -h127.0.0.1 -p"$port" -U"$user" -d "$db" --no-owner --no-privileges \ > "/workspace/$out" ' sh "$db" "$out" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" - fi - echo "OK: dumped '$db' -> ./$out" >&2 - ;; + fi + echo "OK: dumped '$db' -> ./$out" >&2 + ;; - dump-all) - shift - local out="${1:-all.sql}" - if [[ "$out" == *.gz ]]; then - run_pg_mount "$IMAGE" sh -lc ' + dump-all) + shift + local out="${1:-all.sql}" + if [[ "$out" == *.gz ]]; then + run_pg_mount "$IMAGE" sh -lc ' out="$1"; user="$2"; pass="$3"; port="$4" PGPASSWORD="$pass" pg_dumpall -h127.0.0.1 -p"$port" -U"$user" \ | gzip -c > "/workspace/$out" ' sh "$out" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" - else - run_pg_mount "$IMAGE" sh -lc ' + else + run_pg_mount "$IMAGE" sh -lc ' out="$1"; user="$2"; pass="$3"; port="$4" PGPASSWORD="$pass" pg_dumpall -h127.0.0.1 -p"$port" -U"$user" \ > "/workspace/$out" ' sh "$out" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" - fi - echo "OK: dumped all databases -> ./$out" >&2 - ;; + fi + echo "OK: dumped all databases -> ./$out" >&2 + ;; - roles) - shift - local out="${1:-globals.sql}" - if [[ "$out" == *.gz ]]; then - run_pg_mount "$IMAGE" sh -lc ' + roles) + shift + local out="${1:-globals.sql}" + if [[ "$out" == *.gz ]]; then + run_pg_mount "$IMAGE" sh -lc ' out="$1"; user="$2"; pass="$3"; port="$4" PGPASSWORD="$pass" pg_dumpall -h127.0.0.1 -p"$port" -U"$user" --globals-only \ | gzip -c > "/workspace/$out" ' sh "$out" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" - else - run_pg_mount "$IMAGE" sh -lc ' + else + run_pg_mount "$IMAGE" sh -lc ' out="$1"; user="$2"; pass="$3"; port="$4" PGPASSWORD="$pass" pg_dumpall -h127.0.0.1 -p"$port" -U"$user" --globals-only \ > "/workspace/$out" ' sh "$out" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" - fi - echo "OK: dumped globals (roles) -> ./$out" >&2 - ;; - - schema) - shift - local db="${1:-$PGDB}" - [[ -n "$db" ]] || die "schema requires a db name." - local out="${2:-${db}.schema.sql}" - if [[ "$out" == *.gz ]]; then - run_pg_mount "$IMAGE" sh -lc ' + fi + echo "OK: dumped globals (roles) -> ./$out" >&2 + ;; + + schema) + shift + local db="${1:-$PGDB}" + [[ -n "$db" ]] || die "schema requires a db name." + local out="${2:-${db}.schema.sql}" + if [[ "$out" == *.gz ]]; then + run_pg_mount "$IMAGE" sh -lc ' db="$1"; out="$2"; user="$3"; pass="$4"; port="$5" PGPASSWORD="$pass" pg_dump -h127.0.0.1 -p"$port" -U"$user" -d "$db" --schema-only --no-owner --no-privileges \ | gzip -c > "/workspace/$out" ' sh "$db" "$out" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" - else - run_pg_mount "$IMAGE" sh -lc ' + else + run_pg_mount "$IMAGE" sh -lc ' db="$1"; out="$2"; user="$3"; pass="$4"; port="$5" PGPASSWORD="$pass" pg_dump -h127.0.0.1 -p"$port" -U"$user" -d "$db" --schema-only --no-owner --no-privileges \ > "/workspace/$out" ' sh "$db" "$out" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" - fi - echo "OK: schema dumped '$db' -> ./$out" >&2 - ;; - - data) - shift - local db="${1:-$PGDB}" - [[ -n "$db" ]] || die "data requires a db name." - local out="${2:-${db}.data.sql}" - if [[ "$out" == *.gz ]]; then - run_pg_mount "$IMAGE" sh -lc ' + fi + echo "OK: schema dumped '$db' -> ./$out" >&2 + ;; + + data) + shift + local db="${1:-$PGDB}" + [[ -n "$db" ]] || die "data requires a db name." + local out="${2:-${db}.data.sql}" + if [[ "$out" == *.gz ]]; then + run_pg_mount "$IMAGE" sh -lc ' db="$1"; out="$2"; user="$3"; pass="$4"; port="$5" PGPASSWORD="$pass" pg_dump -h127.0.0.1 -p"$port" -U"$user" -d "$db" --data-only --no-owner --no-privileges \ | gzip -c > "/workspace/$out" ' sh "$db" "$out" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" - else - run_pg_mount "$IMAGE" sh -lc ' + else + run_pg_mount "$IMAGE" sh -lc ' db="$1"; out="$2"; user="$3"; pass="$4"; port="$5" PGPASSWORD="$pass" pg_dump -h127.0.0.1 -p"$port" -U"$user" -d "$db" --data-only --no-owner --no-privileges \ > "/workspace/$out" ' sh "$db" "$out" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" - fi - echo "OK: data dumped '$db' -> ./$out" >&2 - ;; - - list) - shift - local file="${1:-}" - [[ -n "$file" ]] || die "list requires a dump file." - [[ -f "$file" ]] || die "file not found: $file" - - local ws="" - ws="$(to_workspace_path_under_mount "$file" || true)" - if [[ -n "$ws" ]]; then - run_pg_mount "$IMAGE" sh -lc ' + fi + echo "OK: data dumped '$db' -> ./$out" >&2 + ;; + + list) + shift + local file="${1:-}" + [[ -n "$file" ]] || die "list requires a dump file." + [[ -f "$file" ]] || die "file not found: $file" + + local ws="" + ws="$(to_workspace_path_under_mount "$file" || true)" + if [[ -n "$ws" ]]; then + run_pg_mount "$IMAGE" sh -lc ' file="$1"; user="$2"; pass="$3"; port="$4" PGPASSWORD="$pass" exec pg_restore -h127.0.0.1 -p"$port" -U"$user" -l "$file" ' sh "$ws" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" - else - cat "$file" | docker exec -i -e "PGPASSWORD=$PGPASS" "$SERVICE" \ - pg_restore -h127.0.0.1 -p"$POSTGRES_PORT_IN" -U"$PGUSER" -l - - fi - ;; - - restore) - shift - local file="${1:-}" - shift || true - [[ -n "$file" ]] || die "restore requires a dump file." - [[ -f "$file" ]] || die "file not found: $file" - - local db="${1:-$PGDB}" - [[ -n "$db" ]] || die "restore requires a db name (or set POSTGRES_DATABASE)." - ensure_db "$db" "$PGUSER" "$PGPASS" - - local ws="" - ws="$(to_workspace_path_under_mount "$file" || true)" - if [[ -n "$ws" ]]; then - run_pg_mount "$IMAGE" sh -lc ' + else + cat "$file" | docker exec -i -e "PGPASSWORD=$PGPASS" "$SERVICE" \ + pg_restore -h127.0.0.1 -p"$POSTGRES_PORT_IN" -U"$PGUSER" -l - + fi + ;; + + restore) + shift + local file="${1:-}" + shift || true + [[ -n "$file" ]] || die "restore requires a dump file." + [[ -f "$file" ]] || die "file not found: $file" + + local db="${1:-$PGDB}" + [[ -n "$db" ]] || die "restore requires a db name (or set POSTGRES_DATABASE)." + ensure_db "$db" "$PGUSER" "$PGPASS" + + local ws="" + ws="$(to_workspace_path_under_mount "$file" || true)" + if [[ -n "$ws" ]]; then + run_pg_mount "$IMAGE" sh -lc ' file="$1"; db="$2"; user="$3"; pass="$4"; port="$5" PGPASSWORD="$pass" exec pg_restore -h127.0.0.1 -p"$port" -U"$user" -d "$db" \ --clean --if-exists --no-owner --no-privileges "$file" ' sh "$ws" "$db" "$PGUSER" "$PGPASS" "$POSTGRES_PORT_IN" - else - cat "$file" | docker exec -i -e "PGPASSWORD=$PGPASS" "$SERVICE" \ - pg_restore -h127.0.0.1 -p"$POSTGRES_PORT_IN" -U"$PGUSER" -d "$db" \ - --clean --if-exists --no-owner --no-privileges - - fi + else + cat "$file" | docker exec -i -e "PGPASSWORD=$PGPASS" "$SERVICE" \ + pg_restore -h127.0.0.1 -p"$POSTGRES_PORT_IN" -U"$PGUSER" -d "$db" \ + --clean --if-exists --no-owner --no-privileges - + fi - echo "OK: restored '$file' -> db '$db'" >&2 - ;; + echo "OK: restored '$file' -> db '$db'" >&2 + ;; - *) - # passthrough (psql args...) - psql_exec "${PGDB:-}" "$PGUSER" "$PGPASS" "$@" - ;; + *) + psql_exec "${PGDB:-}" "$PGUSER" "$PGPASS" "$@" + ;; esac } diff --git a/bin/php b/bin/php index ca1955a..cee6731 100755 --- a/bin/php +++ b/bin/php @@ -17,23 +17,84 @@ else C_YELLOW="" C_DIM="" fi + kv() { printf "%s%s%s %s%s%s\n" "$C_CYAN" "$1" "$C_RESET" "$C_YELLOW" "$2" "$C_RESET" >&2; } SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SERVER_TOOLS_CONTAINER="${SERVER_TOOLS_CONTAINER:-SERVER_TOOLS}" normalize_container() { - local v="$1" + local v="${1:-}" [[ -z "$v" ]] && return 0 if [[ "$v" =~ ^PHP_[0-9]+\.[0-9]+$ ]]; then - echo "$v" + printf '%s\n' "$v" elif [[ "$v" =~ ^[0-9]+\.[0-9]+$ ]]; then - echo "PHP_$v" + printf 'PHP_%s\n' "$v" else die "invalid version '$v' (use like 8.4)" fi } +container_is_running() { + local name="$1" + docker ps --format '{{.Names}}' | grep -qx "$name" +} + +running_php_containers() { + docker ps --format '{{.Names}}' | awk '/^PHP_[0-9]+\.[0-9]+$/' | sort -u +} + +pick_highest_php_container() { + local names + names="$(running_php_containers || true)" + [[ -n "$names" ]] || return 1 + printf '%s\n' "$names" | sed 's/^PHP_//' | sort -Vr | head -n1 | awk 'NF{print "PHP_"$0}' +} + +php_container_version() { + local name="$1" + [[ "$name" =~ ^PHP_([0-9]+\.[0-9]+)$ ]] || return 1 + printf '%s\n' "${BASH_REMATCH[1]}" +} + +server_tools_php_version() { + container_is_running "$SERVER_TOOLS_CONTAINER" || return 1 + docker exec "$SERVER_TOOLS_CONTAINER" sh -lc 'command -v php >/dev/null 2>&1 && php -r "echo PHP_MAJOR_VERSION, \".\", PHP_MINOR_VERSION;"' 2>/dev/null | tr -d '\r\n' +} + +resolve_host_pwd() { + local host="${WORKDIR_WIN:-${WORKING_DIR:-}}" + if [[ -z "$host" ]]; then + host="$PWD" + fi + [[ -n "$host" ]] || host="$REPO_ROOT" + + if command -v cygpath >/dev/null 2>&1; then + [[ "$host" == /* ]] && host="$(cygpath -w "$host")" + fi + + case "$host" in + *"Program Files"/Git/workspace*|*"Program Files\\Git\\workspace"*) host="$REPO_ROOT" ;; + esac + + printf '%s' "$host" +} + +is_msys() { + case "${OSTYPE:-}" in + msys*|cygwin*) return 0 ;; + *) return 1 ;; + esac +} + +msys_no_pathconv() { + if is_msys; then + export MSYS_NO_PATHCONV=1 + export MSYS2_ARG_CONV_EXCL='*' + fi +} + relpath_under_pwd() { local p="$1" local cwd abs rel @@ -41,17 +102,15 @@ relpath_under_pwd() { abs="$(cd "$(dirname "$p")" && pwd)/$(basename "$p")" if command -v python3 >/dev/null 2>&1; then - rel="$( - python3 - "$cwd" "$abs" <<'PY' 2>/dev/null || true + rel="$({ python3 - "$cwd" "$abs" <<'PY' import os,sys cwd=sys.argv[1]; p=sys.argv[2] try: - r=os.path.relpath(p,cwd) - print(r) + print(os.path.relpath(p, cwd)) except Exception: pass PY - )" + } 2>/dev/null || true)" else if [[ "$abs" == "$cwd/"* ]]; then rel="${abs#"$cwd/"}" @@ -61,7 +120,7 @@ PY fi [[ -n "${rel:-}" && "$rel" != /* && "$rel" != ..* ]] || return 1 - printf "%s" "$rel" + printf '%s' "$rel" } port_in_use() { @@ -81,39 +140,9 @@ port_in_use() { return 1 } -pick_highest_php_container() { - local names - names="$(docker ps --format '{{.Names}}' | awk '/^PHP_[0-9]+\.[0-9]+$/')" - [[ -n "$names" ]] || return 1 - printf "%s\n" "$names" | sed 's/^PHP_//' | sort -Vr | head -n1 | awk 'NF{print "PHP_"$0}' -} - -resolve_host_pwd() { - local host="${WORKDIR_WIN:-${WORKING_DIR:-}}" - if [[ -z "${host}" ]]; then - host="$PWD" - fi - [[ -n "${host}" ]] || host="$REPO_ROOT" - - if command -v cygpath >/dev/null 2>&1; then - [[ "$host" == /* ]] && host="$(cygpath -w "$host")" - fi - - printf '%s' "$host" -} - -is_msys() { - case "${OSTYPE:-}" in - msys* | cygwin*) return 0 ;; - *) return 1 ;; - esac -} - -msys_no_pathconv() { - if is_msys; then - export MSYS_NO_PATHCONV=1 - export MSYS2_ARG_CONV_EXCL='*' - fi +pick_container_network() { + local name="$1" + docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{println $k}}{{end}}' "$name" 2>/dev/null | awk 'NF{print; exit}' } EXPLICIT_VER="" @@ -121,39 +150,75 @@ ARGS=() while [[ $# -gt 0 ]]; do case "$1" in - -V | --v | --php) - [[ -n "${2:-}" ]] || die "$1 requires a version (e.g. $1 8.4)" - EXPLICIT_VER="$2" - shift 2 - ;; - --) - shift - ARGS+=("$@") - break - ;; - *) - ARGS+=("$1") - shift - ;; + -V|--v|--php) + [[ -n "${2:-}" ]] || die "$1 requires a version (e.g. $1 8.4)" + EXPLICIT_VER="$2" + shift 2 + ;; + --) + shift + ARGS+=("$@") + break + ;; + *) + ARGS+=("$1") + shift + ;; esac done -TARGET="$(normalize_container "$EXPLICIT_VER")" - -if [[ -n "$TARGET" ]]; then - docker ps --format '{{.Names}}' | grep -qx "$TARGET" || die "container '$TARGET' is not running." +TARGET_CONTAINER="" +TARGET_IMAGE="" +NETWORK_SOURCE_CONTAINER="" +RUNTIME_LABEL="" +SERVER_TOOLS_VERSION="$(server_tools_php_version || true)" +NORMALIZED_EXPLICIT="$(normalize_container "$EXPLICIT_VER")" + +if [[ -n "$NORMALIZED_EXPLICIT" ]]; then + if container_is_running "$NORMALIZED_EXPLICIT"; then + TARGET_CONTAINER="$NORMALIZED_EXPLICIT" + NETWORK_SOURCE_CONTAINER="$NORMALIZED_EXPLICIT" + TARGET_IMAGE="$(docker inspect -f '{{.Config.Image}}' "$NORMALIZED_EXPLICIT" 2>/dev/null)" || die "failed to inspect '$NORMALIZED_EXPLICIT'." + RUNTIME_LABEL="$NORMALIZED_EXPLICIT" + elif [[ -n "$SERVER_TOOLS_VERSION" && "$SERVER_TOOLS_VERSION" == "$(php_container_version "$NORMALIZED_EXPLICIT")" ]]; then + TARGET_CONTAINER="$SERVER_TOOLS_CONTAINER" + NETWORK_SOURCE_CONTAINER="$SERVER_TOOLS_CONTAINER" + TARGET_IMAGE="$(docker inspect -f '{{.Config.Image}}' "$SERVER_TOOLS_CONTAINER" 2>/dev/null)" || die "failed to inspect '$SERVER_TOOLS_CONTAINER'." + RUNTIME_LABEL="$SERVER_TOOLS_CONTAINER (php $SERVER_TOOLS_VERSION)" + else + die "requested PHP ${EXPLICIT_VER} is not available in a running PHP container or $SERVER_TOOLS_CONTAINER." + fi else - TARGET="$(pick_highest_php_container)" || die "no PHP container is running (expected names like PHP_8.4)." + if TARGET_CONTAINER="$(pick_highest_php_container)"; then + NETWORK_SOURCE_CONTAINER="$TARGET_CONTAINER" + TARGET_IMAGE="$(docker inspect -f '{{.Config.Image}}' "$TARGET_CONTAINER" 2>/dev/null)" || die "failed to inspect '$TARGET_CONTAINER'." + RUNTIME_LABEL="$TARGET_CONTAINER" + elif [[ -n "$SERVER_TOOLS_VERSION" ]]; then + TARGET_CONTAINER="$SERVER_TOOLS_CONTAINER" + NETWORK_SOURCE_CONTAINER="$SERVER_TOOLS_CONTAINER" + TARGET_IMAGE="$(docker inspect -f '{{.Config.Image}}' "$SERVER_TOOLS_CONTAINER" 2>/dev/null)" || die "failed to inspect '$SERVER_TOOLS_CONTAINER'." + RUNTIME_LABEL="$SERVER_TOOLS_CONTAINER (php $SERVER_TOOLS_VERSION)" + else + die "no PHP container is running and $SERVER_TOOLS_CONTAINER has no usable php runtime." + fi fi -IMAGE="$(docker inspect -f '{{.Config.Image}}' "$TARGET" 2>/dev/null)" || die "failed to inspect '$TARGET'." +[[ -n "$TARGET_IMAGE" ]] || die "failed to resolve runtime image." -kv "Container:" "$TARGET" -kv "Image:" "$IMAGE" +kv "Runtime:" "$RUNTIME_LABEL" +kv "Image:" "$TARGET_IMAGE" HOST_PWD="$(resolve_host_pwd)" msys_no_pathconv +common_run_flags() { + local -n _out="$1" + _out=(--rm -v "$HOST_PWD":/workspace -w /workspace) + [[ -t 0 ]] && _out+=(-it) || _out+=(-i) + _out+=(-u "$(id -u):$(id -g)") + _out+=(-e HOME=/tmp/php-home) +} + serve_mode() { local host="127.0.0.1" local port="8000" @@ -163,35 +228,35 @@ serve_mode() { while [[ $# -gt 0 ]]; do case "$1" in - --host=*) host="${1#*=}" ;; - --host) - shift - host="${1:-}" - ;; - --port=*) port="${1#*=}" ;; - --port) - shift - port="${1:-}" - ;; - --root=*) root_dir="${1#*=}" ;; - --root) - shift - root_dir="${1:-}" - ;; - --router=*) router_file="${1#*=}" ;; - --router) - shift - router_file="${1:-}" - ;; - -v | --verbose) verbose=true ;; - *) die "unknown serve option: $1" ;; + --host=*) host="${1#*=}" ;; + --host) + shift + host="${1:-}" + ;; + --port=*) port="${1#*=}" ;; + --port) + shift + port="${1:-}" + ;; + --root=*) root_dir="${1#*=}" ;; + --root) + shift + root_dir="${1:-}" + ;; + --router=*) router_file="${1#*=}" ;; + --router) + shift + router_file="${1:-}" + ;; + -v|--verbose) verbose=true ;; + *) die "unknown serve option: $1" ;; esac shift done [[ -n "${host:-}" ]] || die "serve: --host requires a value" [[ "$port" =~ ^[0-9]+$ ]] || die "serve: invalid --port '$port'" - ((port >= 1 && port <= 65535)) || die "serve: port out of range: $port" + (( port >= 1 && port <= 65535 )) || die "serve: port out of range: $port" if [[ "$root_dir" != /* ]]; then root_dir="$PWD/$root_dir"; fi [[ -d "$root_dir" ]] || die "serve: root directory not found: $root_dir" @@ -251,19 +316,22 @@ serve_mode() { publish=(-p "${host}:${port}:${port}") fi - local RUN_FLAGS=(--rm -v "$HOST_PWD":/workspace -w /workspace) - [[ -t 0 ]] && RUN_FLAGS+=(-it) || RUN_FLAGS+=(-i) - RUN_FLAGS+=(-u "$(id -u):$(id -g)") - RUN_FLAGS+=("${publish[@]}") + local network_name="" + network_name="$(pick_container_network "$NETWORK_SOURCE_CONTAINER" || true)" + + local run_flags=() + common_run_flags run_flags + run_flags+=("${publish[@]}") + [[ -n "$network_name" ]] && run_flags+=(--network "$network_name") if [[ -n "$router_rel" ]]; then - exec docker run "${RUN_FLAGS[@]}" "$IMAGE" php -S "0.0.0.0:${port}" -t "/workspace/${found_rel#./}" "/workspace/${router_rel#./}" + exec docker run "${run_flags[@]}" "$TARGET_IMAGE" php -S "0.0.0.0:${port}" -t "/workspace/${found_rel#./}" "/workspace/${router_rel#./}" fi if [[ "$found_rel" == "." ]]; then - exec docker run "${RUN_FLAGS[@]}" "$IMAGE" php -S "0.0.0.0:${port}" + exec docker run "${run_flags[@]}" "$TARGET_IMAGE" php -S "0.0.0.0:${port}" else - exec docker run "${RUN_FLAGS[@]}" "$IMAGE" php -S "0.0.0.0:${port}" -t "/workspace/${found_rel#./}" + exec docker run "${run_flags[@]}" "$TARGET_IMAGE" php -S "0.0.0.0:${port}" -t "/workspace/${found_rel#./}" fi } @@ -271,8 +339,12 @@ if [[ "${ARGS[0]:-}" == "serve" ]]; then serve_mode "${ARGS[@]:1}" fi -RUN_FLAGS=(--rm -v "$HOST_PWD":/workspace -w /workspace) -[[ -t 0 ]] && RUN_FLAGS+=(-it) || RUN_FLAGS+=(-i) -RUN_FLAGS+=(-u "$(id -u):$(id -g)") +RUN_FLAGS=() +common_run_flags RUN_FLAGS +RUN_FLAGS+=(--network "container:${NETWORK_SOURCE_CONTAINER}") + +if [[ ${#ARGS[@]} -eq 0 ]]; then + exec docker run "${RUN_FLAGS[@]}" "$TARGET_IMAGE" php +fi -exec docker run "${RUN_FLAGS[@]}" "$IMAGE" php "${ARGS[@]}" +exec docker run "${RUN_FLAGS[@]}" "$TARGET_IMAGE" php "${ARGS[@]}" diff --git a/bin/redis-cli b/bin/redis-cli index 8a82277..eb7009e 100755 --- a/bin/redis-cli +++ b/bin/redis-cli @@ -1,6 +1,7 @@ #!/usr/bin/env bash set -euo pipefail + die() { echo "Error: $*" >&2 exit 1 @@ -29,8 +30,13 @@ fi SERVICE="${REDIS_CONTAINER:-REDIS}" -require_container() { docker inspect "$SERVICE" >/dev/null 2>&1 || die "container '$SERVICE' is not running."; } -is_tty() { [[ -t 0 ]] && echo "-it" || echo "-i"; } +require_container() { + local running + running="$(docker inspect -f '{{.State.Running}}' "$SERVICE" 2>/dev/null || true)" + [[ "$running" == "true" ]] || die "container '$SERVICE' is not running." +} + +is_tty() { [[ -t 0 && -t 1 ]] && echo "-it" || echo "-i"; } # Read env var from container (best source of truth) cenv() { @@ -40,28 +46,35 @@ cenv() { redis_password() { local pw="${REDIS_PASSWORD:-}" - [[ -n "$pw" ]] && { + if [[ -n "$pw" ]]; then printf '%s' "$pw" return 0 - } + fi + pw="$(cenv REDIS_PASSWORD || true)" + if [[ -z "$pw" ]]; then + pw="$(cenv REDISCLI_AUTH || true)" + fi + printf '%s' "$pw" } redis_db() { local db="${REDIS_DB:-}" [[ -n "$db" ]] || db="0" + [[ "$db" =~ ^[0-9]+$ ]] || die "REDIS_DB must be numeric." printf '%s' "$db" } usage() { cat >&2 <<'TXT' Usage: - redis-cli # interactive + redis-cli # interactive redis-cli # passthrough Shortcuts: redis-cli db [cmd...] # run on a specific DB index + redis-cli ping redis-cli status TXT } @@ -72,6 +85,7 @@ redis_exec() { local pw db pw="$(redis_password)" db="$(redis_db)" + if [[ -n "$pw" ]]; then exec docker exec $flags -e "REDISCLI_AUTH=$pw" "$SERVICE" redis-cli -n "$db" "$@" else @@ -96,6 +110,9 @@ main() { docker ps --filter "name=^/${SERVICE}$" --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" exit 0 ;; + ping) + redis_exec PING + ;; db) shift local idx="${1:-}" diff --git a/bin/tool-runner b/bin/tool-runner new file mode 100755 index 0000000..fe32d71 --- /dev/null +++ b/bin/tool-runner @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +set -euo pipefail + +DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P)" +SERVER_TOOLS_CONTAINER="SERVER_TOOLS" + +has_bin() { type -P -- "${1:?}" >/dev/null 2>&1; } +bin_path() { type -P -- "${1:?}"; } + +die() { + printf 'Error: %s\n' "$*" >&2 + exit 1 +} + +load_env_file() { + local env_file="$DIR/docker/.env" + [[ -f "$env_file" ]] || return 0 + + local line key value + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line#"${line%%[![:space:]]*}"}" + [[ -z "$line" || "${line:0:1}" == "#" ]] && continue + [[ "$line" == export\ * ]] && line="${line#export }" + + [[ "$line" == *=* ]] || continue + key="${line%%=*}" + value="${line#*=}" + + key="${key%"${key##*[![:space:]]}"}" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + + if [[ "$value" =~ ^\".*\"$ ]]; then + value="${value:1:${#value}-2}" + elif [[ "$value" =~ ^\'.*\'$ ]]; then + value="${value:1:${#value}-2}" + fi + + case "$key" in + WORKING_DIR|WORKDIR|WORKDIR_WIN) + export "$key=$value" + ;; + esac + done < "$env_file" +} + +server_tools_running() { + has_bin docker || return 1 + [[ "$("$(bin_path docker)" inspect -f '{{.State.Running}}' "$SERVER_TOOLS_CONTAINER" 2>/dev/null || true)" == "true" ]] +} + +server_tools_has() { + local cmd="${1:-}" + [[ -n "$cmd" ]] || return 1 + server_tools_running || return 1 + "$(bin_path docker)" exec "$SERVER_TOOLS_CONTAINER" sh -lc 'command -v -- "$1" >/dev/null 2>&1' sh "$cmd" +} + +server_tools_image() { + "$(bin_path docker)" inspect -f '{{.Config.Image}}' "$SERVER_TOOLS_CONTAINER" 2>/dev/null +} + +server_tools_env_value() { + local key="${1:?}" + "$(bin_path docker)" inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$SERVER_TOOLS_CONTAINER" 2>/dev/null \ + | awk -F= -v k="$key" '$1==k { sub(/^[^=]*=/, ""); print; exit }' +} + +resolve_workspace() { + local workdir git_root + + workdir="${WORKDIR_WIN:-${WORKING_DIR:-${WORKDIR:-$PWD}}}" + [[ -n "$workdir" ]] || workdir="$DIR" + + if [[ -n "${MSYSTEM:-}${CYGWIN:-}" ]]; then + if [[ "$workdir" == *"/Program Files/Git/workspace"* ]] || [[ "$workdir" == "/workspace"* ]]; then + if has_bin git; then + git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)" + [[ -n "$git_root" ]] && workdir="$git_root" + fi + fi + + if has_bin cygpath; then + cygpath -w "$workdir" + return 0 + fi + fi + + printf '%s\n' "$workdir" +} + +main() { + load_env_file + + local cmd="${1:-}" + shift || true + + [[ -n "$cmd" ]] || die "missing command" + has_bin docker || die "docker is required on host" + server_tools_running || die "$SERVER_TOOLS_CONTAINER is not running" + server_tools_has "$cmd" || die "command '$cmd' not found in $SERVER_TOOLS_CONTAINER" + + local image workspace tz username home_dir + image="$(server_tools_image)" || die "unable to inspect $SERVER_TOOLS_CONTAINER image" + [[ -n "$image" ]] || die "unable to resolve $SERVER_TOOLS_CONTAINER image" + + workspace="$(resolve_workspace)" + [[ -n "$workspace" ]] || die "unable to resolve workspace" + + tz="$(server_tools_env_value TZ || true)" + username="$(server_tools_env_value USERNAME || true)" + home_dir="/home/root" + + local -a flags envs + flags=(--rm) + envs=( + -e HOME="$home_dir" + -e USER=root + -e LOGNAME=root + -e TERM="${TERM:-xterm-256color}" + ) + + [[ -n "$tz" ]] && envs+=(-e TZ="$tz") + [[ -n "$username" ]] && envs+=(-e USERNAME="$username") + [[ -n "${COLORTERM:-}" ]] && envs+=(-e COLORTERM="$COLORTERM") + [[ -n "${NO_COLOR:-}" ]] && envs+=(-e NO_COLOR="$NO_COLOR") + [[ -n "${CLICOLOR_FORCE:-}" ]] && envs+=(-e CLICOLOR_FORCE="$CLICOLOR_FORCE") + [[ -n "${FORCE_COLOR:-}" ]] && envs+=(-e FORCE_COLOR="$FORCE_COLOR") + + [[ -t 0 ]] && flags+=(-i) + [[ -t 1 ]] && flags+=(-t) + + if [[ -n "${MSYSTEM:-}${CYGWIN:-}" ]]; then + export MSYS_NO_PATHCONV=1 + export MSYS2_ARG_CONV_EXCL='*' + fi + + flags+=( + --entrypoint "$cmd" + --network "container:$SERVER_TOOLS_CONTAINER" + --volumes-from "$SERVER_TOOLS_CONTAINER" + -v "$workspace:/workspace" + -w /workspace + ) + + exec "$(bin_path docker)" run "${flags[@]}" "${envs[@]}" "$image" "$@" +} + +main "$@" diff --git a/configuration/apache/.gitignore b/configuration/compose/.gitignore similarity index 100% rename from configuration/apache/.gitignore rename to configuration/compose/.gitignore diff --git a/configuration/php/.gitignore b/configuration/php/.gitignore index c96a04f..60bbdd2 100644 --- a/configuration/php/.gitignore +++ b/configuration/php/.gitignore @@ -1,2 +1,3 @@ * +!pools !.gitignore \ No newline at end of file diff --git a/configuration/rootCA/.gitignore b/configuration/rootCA/.gitignore deleted file mode 100644 index c96a04f..0000000 --- a/configuration/rootCA/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore \ No newline at end of file diff --git a/data/.gitignore b/data/.gitignore deleted file mode 100644 index c96a04f..0000000 --- a/data/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore \ No newline at end of file diff --git a/docker/compose/companion.yaml b/docker/compose/companion.yaml index b824041..c13f216 100644 --- a/docker/compose/companion.yaml +++ b/docker/compose/companion.yaml @@ -6,21 +6,29 @@ services: restart: unless-stopped environment: - TZ=${TZ:-} + - USERNAME=${USER} volumes: - - ../../configuration/apache:/etc/share/vhosts/apache - - ../../configuration/nginx:/etc/share/vhosts/nginx - - ../../configuration/node:/etc/share/vhosts/node + - "${PROJECT_DIR:-./../../../application}:/app" + - lds_ssl_roots:/etc/share/rootCA + - lds_apache_host:/etc/share/vhosts/apache + - lds_nginx_host:/etc/share/vhosts/nginx + - lds_ssl_keys:/etc/mkcert + - lds_fpm_pools:/etc/share/vhosts/fpm + - ../../configuration/compose:/etc/share/vhosts/docker-compose + - ../../configuration/scheduler/cron-jobs:/etc/share/scheduler/cron-jobs + - ../../configuration/scheduler/supervisor:/etc/share/scheduler/supervisor - ../../configuration/sops/config:/etc/share/sops/config - ../../configuration/sops/global:/etc/share/sops/global - ../../configuration/sops/keys:/etc/share/sops/keys - - ../../configuration/ssl:/etc/mkcert - "../../configuration/ssh:/home/root/.ssh:ro" - - "${PROJECT_DIR:-./../../../application}:/app" - - ../../configuration/rootCA:/etc/share/rootCA - - /var/run/docker.sock:/var/run/docker.sock - "${SOP_REPO:-./../../../sops-repo}:/etc/share/vhosts/sops" - "${HOME}/.gitconfig:/home/root/.gitconfig:ro" + - ../../configuration/ssl:/etc/share/certs + - ../../logs:/global/log:ro + - /var/run/docker.sock:/var/run/docker.sock networks: + frontend: + ipv4_address: 172.28.0.13 backend: ipv4_address: 172.29.0.10 datastore: @@ -65,13 +73,13 @@ services: - TZ=${TZ:-} - MP_MAX_MESSAGES=5000 - MP_DATABASE=/data/mailpit.db - - MP_SMTP_TLS_CERT=/certs/local.pem - - MP_SMTP_TLS_KEY=/certs/local-key.pem + - MP_SMTP_TLS_CERT=/certs/lds-server.pem + - MP_SMTP_TLS_KEY=/certs/lds-server-key.pem - MP_SMTP_REQUIRE_STARTTLS=true - MP_SMTP_AUTH_ACCEPT_ANY=1 volumes: - - ../../data/mailpit:/data - - ../../configuration/ssl:/certs:ro + - lds_ssl_keys:/certs:ro + - lds_mail:/data depends_on: - server-tools networks: diff --git a/docker/compose/db-client.yaml b/docker/compose/db-client.yaml index 395a10a..1376461 100644 --- a/docker/compose/db-client.yaml +++ b/docker/compose/db-client.yaml @@ -16,7 +16,7 @@ services: environment: - RI_REDIS_HOST=redis volumes: - - ../../data/redis-insight:/data + - lds_ri:/data - ../../logs/redis-insight:/var/log/redis-insight networks: datastore: @@ -33,8 +33,13 @@ services: environment: - TZ=${TZ:-} volumes: - - ../../data/cloudbeaver:/opt/cloudbeaver/workspace + - lds_cb:/opt/cloudbeaver/workspace - ../../logs/cloudbeaver:/opt/cloudbeaver/logs + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8978/"] + interval: 30s + timeout: 10s + retries: 3 networks: datastore: ipv4_address: 172.30.0.151 @@ -75,7 +80,7 @@ services: - TZ=${TZ:-} - "ELASTICSEARCH_HOSTS=http://elasticsearch:9200" volumes: - - ../../data/kibana:/usr/share/kibana/data + - lds_kb:/usr/share/kibana/data - ../../logs/kibana:/usr/share/kibana/logs networks: datastore: @@ -88,13 +93,13 @@ services: container_name: FILEBEAT restart: unless-stopped user: root - profiles: [elasticsearch] + profiles: [filebeat] depends_on: - elasticsearch volumes: + - lds_fbeat:/usr/share/filebeat/data - ../conf/filebeat.yml:/usr/share/filebeat/filebeat.yml:ro - ../../logs:/global/log:ro - - ../../data/filebeat:/usr/share/filebeat/data command: ["--strict.perms=false"] networks: datastore: diff --git a/docker/compose/db.yaml b/docker/compose/db.yaml index 51b004d..9af78c1 100644 --- a/docker/compose/db.yaml +++ b/docker/compose/db.yaml @@ -11,7 +11,7 @@ services: image: redis/redis-stack-server:${REDIS_VERSION:-latest} profiles: [redis] volumes: - - ../../data/redis:/data + - lds_redis:/data - ../../logs/redis:/var/log/redis environment: - TZ=${TZ:-} @@ -37,7 +37,7 @@ services: - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} - POSTGRES_DB=${POSTGRES_DATABASE:-postgres} volumes: - - ../../data/postgresql:/var/lib/postgresql + - lds_pg:/var/lib/postgresql - ../../logs/postgresql:/var/log/postgresql - ../conf/pg_hba.conf:/etc/postgresql/pg_hba.conf # - ../conf/postgresql.conf:/etc/postgresql/postgresql.conf @@ -73,7 +73,7 @@ services: - MYSQL_PASSWORD=${MYSQL_PASSWORD:-12345} - MYSQL_DATABASE=${MYSQL_DATABASE:-localdb} volumes: - - ../../data/mysql:/var/lib/mysql + - lds_my:/var/lib/mysql - ../../logs/mysql:/var/log/mysql healthcheck: test: ["CMD-SHELL", "mysqladmin ping -h127.0.0.1 -u root -p${MYSQL_ROOT_PASSWORD:-12345}"] @@ -95,7 +95,7 @@ services: - MONGO_INITDB_ROOT_USERNAME=${MONGODB_ROOT_USERNAME:-root} - MONGO_INITDB_ROOT_PASSWORD=${MONGODB_ROOT_PASSWORD:-12345} volumes: - - ../../data/mongo:/data/db + - lds_mongo:/data/db - ../../logs/mongodb:/var/log/mongodb healthcheck: test: @@ -123,7 +123,7 @@ services: - MARIADB_PASSWORD=${MARIADB_PASSWORD:-12345} - MARIADB_DATABASE=${MARIADB_DATABASE:-localdb} volumes: - - ../../data/mariadb:/var/lib/mysql + - lds_maria:/var/lib/mysql - ../../logs/mariadb:/var/log/mysql healthcheck: test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -p${MARIADB_ROOT_PASSWORD:-12345}"] @@ -147,7 +147,7 @@ services: - "cluster.name=single_node_cluster" - "node.name=elasticsearch-node-single" volumes: - - ../../data/elasticsearch:/usr/share/elasticsearch/data + - lds_es:/usr/share/elasticsearch/data - ../../logs/elasticsearch:/usr/share/elasticsearch/logs healthcheck: test: ["CMD-SHELL", "curl -f http://localhost:9200/_cluster/health || exit 1"] diff --git a/docker/compose/http.yaml b/docker/compose/http.yaml index 19b6766..6091018 100644 --- a/docker/compose/http.yaml +++ b/docker/compose/http.yaml @@ -10,11 +10,12 @@ services: - "${HTTP_PORT:-80}:80" - "${HTTPS_PORT:-443}:443" volumes: - - ../../logs/nginx:/var/log/nginx - - ../../configuration/nginx:/etc/nginx/conf.d:ro - - ../../configuration/ssl:/etc/mkcert:ro - - ../../configuration/rootCA:/etc/share/rootCA:ro - "${PROJECT_DIR:-./../../../application}:/app" + - lds_nginx_host:/etc/nginx/conf.d + - lds_ssl_keys:/etc/mkcert:ro + - lds_ssl_roots:/etc/share/rootCA:ro + - lds_fpm_sock:/run/php-fpm:ro + - ../../logs/nginx:/var/log/nginx extra_hosts: - "host.docker.internal:host-gateway" networks: @@ -28,16 +29,15 @@ services: hostname: apache image: infocyph/apache:latest restart: always - profiles: - - apache environment: - TZ=${TZ:-} volumes: - - ../../configuration/ssl:/etc/mkcert:ro - - ../../configuration/apache:/usr/local/apache2/conf/extra:ro - - ../../logs/apache:/var/log/apache2 - - ../../configuration/rootCA:/etc/share/rootCA:ro - "${PROJECT_DIR:-./../../../application}:/app" + - lds_ssl_keys:/etc/mkcert:ro + - lds_apache_host:/usr/local/apache2/conf/vhosts:ro + - lds_ssl_roots:/etc/share/rootCA:ro + - lds_fpm_sock:/run/php-fpm:ro + - ../../logs/apache:/var/log/apache2 depends_on: - nginx networks: diff --git a/docker/compose/main.yaml b/docker/compose/main.yaml index 9362489..b70fd04 100644 --- a/docker/compose/main.yaml +++ b/docker/compose/main.yaml @@ -1,33 +1,143 @@ name: LocalDevStack - networks: frontend: name: Frontend driver: bridge + labels: + com.docker.compose.project: "LocalDevStack" + com.docker.compose.network: "frontend" + com.infocyph.stack: "LocalDevStack" ipam: config: - subnet: 172.28.0.0/24 gateway: 172.28.0.1 - backend: name: Backend driver: bridge + labels: + com.docker.compose.project: "LocalDevStack" + com.docker.compose.network: "backend" + com.infocyph.stack: "LocalDevStack" ipam: config: - subnet: 172.29.0.0/24 gateway: 172.29.0.1 - datastore: name: DataStore driver: bridge + labels: + com.docker.compose.project: "LocalDevStack" + com.docker.compose.network: "datastore" + com.infocyph.stack: "LocalDevStack" ipam: config: - subnet: 172.30.0.0/24 gateway: 172.30.0.1 - +volumes: + lds_fpm_sock: + name: FPMSocks + labels: + com.infocyph.lds: "1" + com.infocyph.stack: "LocalDevStack" + com.infocyph.purpose: "PHP-FPM Sockets" + lds_fpm_pools: + name: FPMPools + labels: + com.infocyph.lds: "1" + com.infocyph.stack: "LocalDevStack" + com.infocyph.purpose: "PHP-FPM Pools" + lds_ssl_keys: + name: SSLKeys + labels: + com.infocyph.lds: "1" + com.infocyph.stack: "LocalDevStack" + com.infocyph.purpose: "SSL Keys" + lds_ssl_roots: + name: SSLRootCA + labels: + com.infocyph.lds: "1" + com.infocyph.stack: "LocalDevStack" + com.infocyph.purpose: "SSL Root CA" + lds_nginx_host: + name: NginxHosts + labels: + com.infocyph.lds: "1" + com.infocyph.stack: "LocalDevStack" + com.infocyph.purpose: "Nginx Host Configs" + lds_apache_host: + name: ApacheHosts + labels: + com.infocyph.lds: "1" + com.infocyph.stack: "LocalDevStack" + com.infocyph.purpose: "Apache Host Configs" + lds_mail: + name: EmailStore + labels: + com.infocyph.lds: "1" + com.infocyph.stack: "LocalDevStack" + com.infocyph.purpose: "Email Store" + lds_redis: + name: RedisStore + labels: + com.infocyph.lds: "1" + com.infocyph.stack: "LocalDevStack" + com.infocyph.purpose: "Redis Data" + lds_ri: + name: RedisInsightStore + labels: + com.infocyph.lds: "1" + com.infocyph.stack: "LocalDevStack" + com.infocyph.purpose: "Redis Insight Data" + lds_pg: + name: PostgresStore + labels: + com.infocyph.lds: "1" + com.infocyph.stack: "LocalDevStack" + com.infocyph.purpose: "PostgreSQL Data" + lds_my: + name: MySQLStore + labels: + com.infocyph.lds: "1" + com.infocyph.stack: "LocalDevStack" + com.infocyph.purpose: "MySQL Data" + lds_maria: + name: MariaDBStore + labels: + com.infocyph.lds: "1" + com.infocyph.stack: "LocalDevStack" + com.infocyph.purpose: "MariaDB Data" + lds_mongo: + name: MongoDBStore + labels: + com.infocyph.lds: "1" + com.infocyph.stack: "LocalDevStack" + com.infocyph.purpose: "MongoDB Data" + lds_es: + name: ElasticSearchStore + labels: + com.infocyph.lds: "1" + com.infocyph.stack: "LocalDevStack" + com.infocyph.purpose: "ElasticSearch Data" + lds_kb: + name: KibanaStore + labels: + com.infocyph.lds: "1" + com.infocyph.stack: "LocalDevStack" + com.infocyph.purpose: "Kibana Data" + lds_cb: + name: CloudBeaverStore + labels: + com.infocyph.lds: "1" + com.infocyph.stack: "LocalDevStack" + com.infocyph.purpose: "CloudBeaver Data" + lds_fbeat: + name: FilebeatStore + labels: + com.infocyph.lds: "1" + com.infocyph.stack: "LocalDevStack" + com.infocyph.purpose: "Filebeat Data" include: - docker/compose/companion.yaml - docker/compose/db.yaml - docker/compose/db-client.yaml - - docker/compose/php.yaml - docker/compose/http.yaml diff --git a/docker/compose/php.yaml b/docker/compose/php.yaml deleted file mode 100644 index 657ac84..0000000 --- a/docker/compose/php.yaml +++ /dev/null @@ -1,234 +0,0 @@ -x-php-service: &php-service - restart: unless-stopped - environment: - - TZ=${TZ:-} - env_file: - - "../../.env" - networks: - frontend: {} - backend: {} - datastore: {} - volumes: - - "${PROJECT_DIR:-./../../../application}:/app" - - ../conf/www.conf:/usr/local/etc/php-fpm.d/www.conf - - ../conf/openssl.cnf:/etc/ssl/openssl.cnf - - ../../configuration/php/php.ini:/usr/local/etc/php/conf.d/99-overrides.ini - - "../../configuration/ssh:/home/${USER}/.ssh:ro" - - "${HOME}/.gitconfig:/home/${USER}/.gitconfig:ro" - - ../../configuration/rootCA:/etc/share/rootCA:ro - depends_on: - - server-tools - healthcheck: - test: ["CMD-SHELL", "php-fpm -t || exit 1"] - interval: 30s - timeout: 10s - retries: 3 - -services: - php73: - <<: *php-service - container_name: PHP_7.3 - hostname: php-73 - networks: - frontend: - ipv4_address: 172.28.0.50 - backend: - ipv4_address: 172.29.0.50 - datastore: - ipv4_address: 172.30.0.50 - build: - context: ../dockerfiles - dockerfile: php.Dockerfile - args: - UID: ${UID:-1000} - GID: ${GID:-root} - USERNAME: ${USER} - PHP_EXT: ${PHP_EXT:-} - LINUX_PKG: ${LINUX_PKG:-} - WORKING_DIR: ${WORKING_DIR} - PHP_VERSION: 7.3 - PHP_EXT_VERSIONED: ${PHP_EXT_73:-} - LINUX_PKG_VERSIONED: ${LINUX_PKG_73:-} - profiles: [php, php73] - - php74: - <<: *php-service - container_name: PHP_7.4 - hostname: php-74 - networks: - frontend: - ipv4_address: 172.28.0.51 - backend: - ipv4_address: 172.29.0.51 - datastore: - ipv4_address: 172.30.0.51 - build: - context: ../dockerfiles - dockerfile: php.Dockerfile - args: - UID: ${UID:-1000} - GID: ${GID:-1000} - USERNAME: ${USER} - PHP_EXT: ${PHP_EXT:-} - LINUX_PKG: ${LINUX_PKG:-} - WORKING_DIR: ${WORKING_DIR} - PHP_VERSION: 7.4 - PHP_EXT_VERSIONED: ${PHP_EXT_74:-} - LINUX_PKG_VERSIONED: ${LINUX_PKG_74:-} - profiles: [php, php74] - - php80: - <<: *php-service - container_name: PHP_8.0 - hostname: php-80 - networks: - frontend: - ipv4_address: 172.28.0.52 - backend: - ipv4_address: 172.29.0.52 - datastore: - ipv4_address: 172.30.0.52 - build: - context: ../dockerfiles - dockerfile: php.Dockerfile - args: - UID: ${UID:-1000} - GID: ${GID:-root} - USERNAME: ${USER} - PHP_EXT: ${PHP_EXT:-} - LINUX_PKG: ${LINUX_PKG:-} - WORKING_DIR: ${WORKING_DIR} - PHP_VERSION: 8.0 - PHP_EXT_VERSIONED: ${PHP_EXT_80:-} - LINUX_PKG_VERSIONED: ${LINUX_PKG_80:-} - profiles: [php, php80] - - php81: - <<: *php-service - container_name: PHP_8.1 - hostname: php-81 - networks: - frontend: - ipv4_address: 172.28.0.53 - backend: - ipv4_address: 172.29.0.53 - datastore: - ipv4_address: 172.30.0.53 - build: - context: ../dockerfiles - dockerfile: php.Dockerfile - args: - UID: ${UID:-1000} - GID: ${GID:-root} - USERNAME: ${USER} - PHP_EXT: ${PHP_EXT:-} - LINUX_PKG: ${LINUX_PKG:-} - WORKING_DIR: ${WORKING_DIR} - PHP_VERSION: 8.1 - PHP_EXT_VERSIONED: ${PHP_EXT_81:-} - LINUX_PKG_VERSIONED: ${LINUX_PKG_81:-} - profiles: [php, php81] - - php82: - <<: *php-service - container_name: PHP_8.2 - hostname: php-82 - networks: - frontend: - ipv4_address: 172.28.0.54 - backend: - ipv4_address: 172.29.0.54 - datastore: - ipv4_address: 172.30.0.54 - build: - context: ../dockerfiles - dockerfile: php.Dockerfile - args: - UID: ${UID:-1000} - GID: ${GID:-root} - USERNAME: ${USER} - PHP_EXT: ${PHP_EXT:-} - LINUX_PKG: ${LINUX_PKG:-} - WORKING_DIR: ${WORKING_DIR} - PHP_VERSION: 8.2 - PHP_EXT_VERSIONED: ${PHP_EXT_82:-} - LINUX_PKG_VERSIONED: ${LINUX_PKG_82:-} - profiles: [php, php82] - - php83: - <<: *php-service - container_name: PHP_8.3 - hostname: php-83 - networks: - frontend: - ipv4_address: 172.28.0.55 - backend: - ipv4_address: 172.29.0.55 - datastore: - ipv4_address: 172.30.0.55 - build: - context: ../dockerfiles - dockerfile: php.Dockerfile - args: - UID: ${UID:-1000} - GID: ${GID:-root} - USERNAME: ${USER} - PHP_EXT: ${PHP_EXT:-} - LINUX_PKG: ${LINUX_PKG:-} - WORKING_DIR: ${WORKING_DIR} - PHP_VERSION: 8.3 - PHP_EXT_VERSIONED: ${PHP_EXT_83:-} - LINUX_PKG_VERSIONED: ${LINUX_PKG_83:-} - profiles: [php, php83] - - php84: - <<: *php-service - container_name: PHP_8.4 - hostname: php-84 - networks: - frontend: - ipv4_address: 172.28.0.56 - backend: - ipv4_address: 172.29.0.56 - datastore: - ipv4_address: 172.30.0.56 - build: - context: ../dockerfiles - dockerfile: php.Dockerfile - args: - UID: ${UID:-1000} - GID: ${GID:-root} - USERNAME: ${USER} - PHP_EXT: ${PHP_EXT:-} - LINUX_PKG: ${LINUX_PKG:-} - WORKING_DIR: ${WORKING_DIR} - PHP_VERSION: 8.4 - PHP_EXT_VERSIONED: ${PHP_EXT_84:-} - LINUX_PKG_VERSIONED: ${LINUX_PKG_84:-} - profiles: [php, php84] - - php85: - <<: *php-service - container_name: PHP_8.5 - hostname: php-85 - networks: - frontend: - ipv4_address: 172.28.0.57 - backend: - ipv4_address: 172.29.0.57 - datastore: - ipv4_address: 172.30.0.57 - build: - context: ../dockerfiles - dockerfile: php.Dockerfile - args: - UID: ${UID:-1000} - GID: ${GID:-root} - USERNAME: ${USER} - PHP_EXT: ${PHP_EXT:-} - LINUX_PKG: ${LINUX_PKG:-} - WORKING_DIR: ${WORKING_DIR} - PHP_VERSION: 8.5 - PHP_EXT_VERSIONED: ${PHP_EXT_85:-} - LINUX_PKG_VERSIONED: ${LINUX_PKG_85:-} - profiles: [php, php85] diff --git a/docker/conf/filebeat.yml b/docker/conf/filebeat.yml index 9fc7b3e..e859977 100644 --- a/docker/conf/filebeat.yml +++ b/docker/conf/filebeat.yml @@ -5,27 +5,96 @@ filebeat.inputs: paths: - /global/log/*/*.log - /global/log/*/*/*.log - exclude_files: ['-\d{8}(\.gz)?$'] + exclude_files: + - '-\d{8}(\.gz)?$' + - '^/global/log/php-fpm/.*\.error\.log$' + - '^/global/log/php-fpm/.*\.access\.log$' processors: - dissect: tokenizer: "/global/log/%{service}/%{file}" field: "log.file.path" target_prefix: "" + # Try: domain.access.log / domain.error.log / anything..log + - dissect: + tokenizer: "%{stem}.%{log_kind}.log" + field: "file" + target_prefix: "" + ignore_failure: true + # Fallback for logs that don't match "..log" (e.g. mysql.log) + - add_fields: + when: + not: + has_fields: ["log_kind"] + target: "" + fields: + log_kind: "app" - convert: fields: - {from: "service", to: "service", type: "string"} ignore_missing: true fail_on_error: false + - type: filestream + id: php-fpm-error + enabled: true + paths: + - /global/log/php-fpm/*.error.log + exclude_files: ['-\d{8}(\.gz)?$'] + parsers: + - multiline: + type: pattern + # PHP error lines usually start: [23-Feb-2026 12:54:42] ... + pattern: '^\[[0-9]{2}-[A-Za-z]{3}-[0-9]{4} ' + negate: true + match: after + processors: + - dissect: + tokenizer: "/global/log/%{service}/%{file}" + field: "log.file.path" + target_prefix: "" + - dissect: + tokenizer: "%{domain}.%{log_kind}.log" + field: "file" + target_prefix: "" + ignore_failure: true + - add_fields: + target: "" + fields: + log_kind: "error" + + - type: filestream + id: php-fpm-access + enabled: true + paths: + - /global/log/php-fpm/*.access.log + exclude_files: ['-\d{8}(\.gz)?$'] + processors: + - dissect: + tokenizer: "/global/log/%{service}/%{file}" + field: "log.file.path" + target_prefix: "" + - dissect: + tokenizer: "%{domain}.%{log_kind}.log" + field: "file" + target_prefix: "" + ignore_failure: true + - add_fields: + target: "" + fields: + log_kind: "access" + output.elasticsearch: hosts: ["http://elasticsearch:9200"] - index: "lds-%{[service]}-%{+yyyyMMdd}" + index: "lds-%{[service]}-%{[log_kind]}-%{+yyyyMMdd}" + setup.template.enabled: true setup.template.name: "lds" -setup.template.pattern: "lds-*-*" +setup.template.pattern: "lds-*-*-*" setup.template.overwrite: false + setup.dashboards.enabled: true setup.dashboards.retry.enabled: true setup.dashboards.retry.interval: 10s setup.dashboards.retry.maximum: 30 + setup.kibana: host: "http://kibana:5601" \ No newline at end of file diff --git a/docker/conf/www-php.conf b/docker/conf/www-php.conf new file mode 100644 index 0000000..c2918bc --- /dev/null +++ b/docker/conf/www-php.conf @@ -0,0 +1,15 @@ +[www] +listen = 0.0.0.0:9000 +user = www-data +group = www-data + +pm = dynamic +pm.max_children = 30 +pm.start_servers = 3 +pm.min_spare_servers = 2 +pm.max_spare_servers = 8 +pm.max_requests = 300 + +catch_workers_output = yes +php_admin_flag[log_errors] = on +php_admin_value[error_log] = /var/log/php-fpm/_default.error.log \ No newline at end of file diff --git a/docker/dockerfiles/node.Dockerfile b/docker/dockerfiles/node.Dockerfile index 8f0321e..016b1ed 100644 --- a/docker/dockerfiles/node.Dockerfile +++ b/docker/dockerfiles/node.Dockerfile @@ -1,7 +1,7 @@ ARG NODE_VERSION=current FROM node:${NODE_VERSION}-alpine -LABEL org.opencontainers.image.source="https://github.com/infocyph/LocalDock" +LABEL org.opencontainers.image.source="https://github.com/infocyph/LocalDevStack" LABEL org.opencontainers.image.description="NodeJS Alpine" LABEL org.opencontainers.image.licenses="MIT" LABEL org.opencontainers.image.authors="infocyph,abmmhasan" @@ -17,18 +17,17 @@ ARG NODE_GLOBAL_VERSIONED ENV PATH="/usr/local/bin:/usr/bin:/bin:/usr/games:$PATH" \ LANG=en_US.UTF-8 \ LC_ALL=en_US.UTF-8 \ - NPM_CONFIG_CACHE=/tmp/.npm-cache \ + NPM_CONFIG_CACHE=/home/${USERNAME}/.npm \ GIT_CREDENTIAL_STORE=/home/${USERNAME}/.git-credentials ADD https://raw.githubusercontent.com/infocyph/Scriptomatic/master/bash/node-cli-setup.sh /usr/local/bin/cli-setup.sh RUN apk add --no-cache bash && \ NODE_VERSION="$(node -v | sed 's/^v//')" && \ - bash /usr/local/bin/cli-setup.sh "${USERNAME}" "${NODE_VERSION}" && \ - rm -f /usr/local/bin/cli-setup.sh && \ - rm -rf /var/cache/apk/* /tmp/* /var/tmp/* + bash /usr/local/bin/cli-setup.sh "${USERNAME}" "${NODE_VERSION}" USER ${USERNAME} RUN sudo /usr/local/bin/git-default WORKDIR /app +EXPOSE 3000 ENTRYPOINT ["/usr/local/bin/node-entry"] CMD [] \ No newline at end of file diff --git a/docker/dockerfiles/php.Dockerfile b/docker/dockerfiles/php.Dockerfile index 2f2acc4..42ffc07 100644 --- a/docker/dockerfiles/php.Dockerfile +++ b/docker/dockerfiles/php.Dockerfile @@ -1,7 +1,7 @@ ARG PHP_VERSION=8.4 FROM php:${PHP_VERSION}-fpm-alpine -LABEL org.opencontainers.image.source="https://github.com/infocyph/LocalDock" +LABEL org.opencontainers.image.source="https://github.com/infocyph/LocalDevStack" LABEL org.opencontainers.image.description="PHP FPM Alpine" LABEL org.opencontainers.image.licenses="MIT" LABEL org.opencontainers.image.authors="infocyph,abmmhasan" @@ -23,10 +23,7 @@ ENV GIT_USER_NAME="" \ LC_ALL=en_US.UTF-8 ADD https://raw.githubusercontent.com/infocyph/Scriptomatic/master/bash/php-cli-setup.sh /usr/local/bin/cli-setup.sh -RUN apk add --no-cache bash && \ - bash /usr/local/bin/cli-setup.sh "${USERNAME}" "${PHP_VERSION}" && \ - rm -f /usr/local/bin/cli-setup.sh && \ - rm -rf /var/cache/apk/* /tmp/* /var/tmp/* +RUN apk add --no-cache bash && bash /usr/local/bin/cli-setup.sh "${USERNAME}" "${PHP_VERSION}" USER ${USERNAME} RUN sudo /usr/local/bin/git-default diff --git a/lds b/lds index b4734e4..a3d69b7 100755 --- a/lds +++ b/lds @@ -7,13 +7,275 @@ if [ -z "${BASH_VERSION:-}" ]; then exec /usr/bin/env bash "$0" "$@"; fi set -euo pipefail ############################################################################### -# 0. PATHS & CONSTANTS +# 0. CORE / BOOTSTRAP-SAFE HELPERS (no Docker required) +############################################################################### + +# Color defaults (overridden later by init_colors); keep safe under set -u +BOLD="${BOLD:-}" +DIM="${DIM:-}" +RED="${RED:-}" +GREEN="${GREEN:-}" +CYAN="${CYAN:-}" +YELLOW="${YELLOW:-}" +BLUE="${BLUE:-}" +MAGENTA="${MAGENTA:-}" +NC="${NC:-}" + +die() { + printf "%bError:%b %s +" "${RED:-}" "${NC:-}" "$*" >&2 + exit 1 +} + +############################################################################### +# Command presence + tool proxy rules +# - Host-only commands MUST exist on host (e.g. docker) +# - Proxy-safe tools may run from the project tools container when LDS_PROXY_TOOLS=1 and container is up +# - Anything inside `docker exec ... sh -lc '...'` must continue to use `command -v` +############################################################################### + +# Cached executable lookup (host binaries only; ignores shell functions/aliases) +declare -A __LDS_HAS_BIN=() + +# Back-compat: treat has_cmd as 'host binary exists' (functions/aliases do not count). +has_cmd() { has_bin "$@"; } + +has_bin() { + local c="${1:-}" + [[ -n "$c" ]] || return 1 + if [[ -n "${__LDS_HAS_BIN[$c]+x}" ]]; then + return "${__LDS_HAS_BIN[$c]}" + fi + type -P -- "$c" >/dev/null 2>&1 + __LDS_HAS_BIN[$c]=$? + return "${__LDS_HAS_BIN[$c]}" +} + +bin_path() { type -P -- "${1:?}"; } + +need_bin() { + local c="${1:-}" hint="${2:-}" + has_bin "$c" && return 0 + die "Missing required command: $c${hint:+ ($hint)}" +} + +# Commands that must NEVER be proxied to project tools container (host control plane / privileged / filesystem) +_is_host_only_cmd() { + case "${1:-}" in + docker | sudo | su | systemctl | service | ip | iptables | nft | sysctl | mount | umount | modprobe | insmod | rmmod | rm | mv | cp | chmod | chown) + return 0 + ;; + esac + return 1 +} + +# Resolve the project-specific tools container name. +_project_tools_container() { + has_bin docker || return 1 + + local project ctr + project="$(lds_project)" + + ctr="$( + docker ps -a \ + --filter "label=com.docker.compose.project=$project" \ + --filter 'label=com.docker.compose.service=server-tools' \ + --format '{{.Names}}' 2>/dev/null | sed -n '1p' + )" + [[ -n "$ctr" ]] && { + printf '%s' "$ctr" + return 0 + } + + ctr="$(docker_compose ps -a server-tools --format '{{.Name}}' 2>/dev/null | sed -n '1p' || true)" + [[ -n "$ctr" ]] && { + printf '%s' "$ctr" + return 0 + } + + return 1 +} + +_project_tools_container_running() { + local ctr + ctr="$(_project_tools_container || true)" + [[ -n "$ctr" ]] || return 1 + docker inspect -f '{{.State.Running}}' "$ctr" 2>/dev/null | grep -qx true || return 1 + printf '%s' "$ctr" +} + +_server_tools_running() { + [[ -n "$(_project_tools_container_running || true)" ]] +} + +_server_tools_has() { + local c="${1:-}" + [[ -n "$c" ]] || return 1 + local ctr + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || return 1 + "$(bin_path docker)" exec "$ctr" sh -lc "command -v \"$c\" >/dev/null 2>&1" >/dev/null 2>&1 +} + +# Unified command runner for proxy-safe tools +# Resolution order (your rule-set): +# 1) if cmd exists on host -> run host binary +# 2) if host-only and missing -> error +# 3) if proxy-safe and LDS_PROXY_TOOLS=1 and project tools up and cmd exists there -> run via docker exec +# 4) otherwise -> error +lds_tools_cmd() { + local cmd="${1:-}" + shift || true + [[ -n "$cmd" ]] || { + echo "lds_tools_cmd: missing command" >&2 + return 2 + } + + # 1) host binary wins + if has_bin "$cmd"; then + local p + p="$(bin_path "$cmd")" + "$p" "$@" + return $? + fi + + # 2) host-only commands must be present on host + if _is_host_only_cmd "$cmd"; then + echo "Error: '$cmd' is required on host but was not found" >&2 + return 127 + fi + + # 3) proxy-safe tools from project tools container (opt-in) + if ((${LDS_PROXY_TOOLS:-1})); then + if ! has_bin docker; then + echo "Error: '$cmd' not available on host; tool proxy requires host 'docker'" >&2 + return 127 + fi + local ctr + ctr="$(_project_tools_container_running || true)" + if [[ -z "$ctr" ]]; then + echo "Error: '$cmd' not available on host and project tools container is not running (set LDS_PROXY_TOOLS=1 and start the stack)" >&2 + return 127 + fi + if ! _server_tools_has "$cmd"; then + echo "Error: '$cmd' not found on host nor inside project tools container" >&2 + return 127 + fi + + local -a flags=() + [[ -t 0 ]] && flags+=(-i) + [[ -t 1 ]] && flags+=(-t) + "$(bin_path docker)" exec "${flags[@]}" "$ctr" "$cmd" "$@" + return $? + fi + + # 4) not found and proxy not enabled + echo "Error: '$cmd' not available on host (set LDS_PROXY_TOOLS=1 to proxy via project tools container)" >&2 + return 127 +} + +# Convenience wrappers for common proxy-safe tools +jq() { lds_tools_cmd jq "$@"; } +yq() { lds_tools_cmd yq "$@"; } +rg() { lds_tools_cmd rg "$@"; } +fd() { lds_tools_cmd fd "$@"; } +tree() { lds_tools_cmd tree "$@"; } +shellcheck() { lds_tools_cmd shellcheck "$@"; } + +# Tool availability checks (host binary OR proxy-available) +has_tool() { + local c="${1:-}" + has_bin "$c" && return 0 + _is_host_only_cmd "$c" && return 1 + ((${LDS_PROXY_TOOLS:-1})) || return 1 + _server_tools_has "$c" +} + +need_tool() { + local c="${1:-}" hint="${2:-}" + has_tool "$c" && return 0 + die "Missing required command: $c${hint:+ ($hint)}" +} + +############################################################################### +# Misc helpers used across the script +############################################################################### + +need() { + local group found cmd + for group in "$@"; do + IFS='|,' read -ra alts <<<"$group" + found=0 + for cmd in "${alts[@]}"; do + has_bin "$cmd" && { + found=1 + break + } + done + ((found)) && continue + local miss=${alts[*]} + miss=${miss// / or } + die "Missing command(s): $miss" + done +} + +# Stream search helper: prefers ripgrep (rg) when available (host or proxied), falls back to grep. +# Reads from stdin, prints matching lines with line numbers. +text_grep() { + local ins=0 + if [[ "${1:-}" == "-i" ]]; then + ins=1 + shift || true + fi + + local pat="${1:-}" + [[ -n "$pat" ]] || return 0 + + if has_tool rg; then + if ((ins)); then rg -n -i -- "$pat"; else rg -n -- "$pat"; fi + else + if ((ins)); then grep -ni -- "$pat"; else grep -n -- "$pat"; fi + fi +} + +ensure_files_exist() { + local rel abs dir + for rel in "$@"; do + abs="${DIR}${rel}" + dir="${abs%/*}" + + if [[ ! -d $dir ]]; then + if mkdir -p "$dir" 2>/dev/null; then + printf "%b- Created directory %s%b\n" "${YELLOW:-}" "$dir" "${NC:-}" + else + printf "%b- Warning:%b cannot create directory %s (permissions?)\n" \ + "${YELLOW:-}" "${NC:-}" "$dir" + continue + fi + elif [[ ! -w $dir ]]; then + printf "%b- Warning:%b directory not writable: %s\n" "${YELLOW:-}" "${NC:-}" "$dir" + fi + + if [[ -e $abs ]]; then + [[ -w $abs ]] || printf "%b- Warning:%b file not writable: %s\n" "${YELLOW:-}" "${NC:-}" "$abs" + else + if : >"$abs" 2>/dev/null; then + printf "%b- Created file %s%b\n" "${YELLOW:-}" "$abs" "${NC:-}" + else + printf "%b- Error:%b cannot create file %s (permissions?)\n" "${RED:-}" "${NC:-}" "$abs" + fi + fi + done +} + +############################################################################### +# 0. BOOTSTRAP / PATHS / CONSTANTS ############################################################################### # Resolve script directory portably (Linux/macOS/WSL) _realpath() { local p="$1" - if command -v realpath >/dev/null 2>&1; then + if has_cmd realpath; then realpath "$p" return 0 fi @@ -25,7 +287,7 @@ _realpath() { fi # macOS with coreutils - if command -v greadlink >/dev/null 2>&1; then + if has_cmd greadlink; then greadlink -f "$p" return 0 fi @@ -42,7 +304,7 @@ CFG="$DIR/docker" ENV_MAIN="$DIR/.env" ENV_DOCKER="$CFG/.env" COMPOSE_FILE="$CFG/compose/main.yaml" -EXTRAS_DIR="$DIR/configuration/node" +EXTRAS_DIR="$DIR/configuration/compose" if [[ "${1:-}" == "--__win_workdir" ]]; then export WORKDIR_WIN="${2:-}" @@ -53,26 +315,47 @@ COLOR() { printf '[%sm' "$1"; } ############################################################################### # Colors + UI (higher contrast; aligned with mkhost.sh) ############################################################################### -BOLD=$'' -DIM=$'' -RED=$'' -GREEN=$'' -CYAN=$'' -YELLOW=$'' -BLUE=$'' -MAGENTA=$'' -NC=$'' - -say() { echo -e "$*"; } -ok() { say "${GREEN}$*${NC}"; } -warn() { say "${YELLOW}$*${NC}"; } -err() { say "${RED}$*${NC}"; } +# Color control: +# - If stdout isn't a TTY, disable colors by default. +# - If NO_COLOR is set, disable colors. +# - Set LDS_FORCE_COLOR=1 to force colors. +_is_tty() { [[ -t 1 ]]; } + +_use_color=1 +if [[ "${LDS_FORCE_COLOR:-0}" != "1" ]]; then + if [[ -n "${NO_COLOR:-}" ]] || ! _is_tty; then + _use_color=0 + fi +fi + +if ((_use_color)); then + BOLD=$'\033[1m' + DIM=$'\033[2m' + RED=$'\033[1;31m' + GREEN=$'\033[1;32m' + CYAN=$'\033[1;36m' + YELLOW=$'\033[1;33m' + BLUE=$'\033[1;34m' + MAGENTA=$'\033[1;35m' + NC=$'\033[0m' +else + BOLD='' DIM='' RED='' GREEN='' CYAN='' YELLOW='' BLUE='' MAGENTA='' NC='' +fi + +# Output control: +# - --quiet suppresses non-error output +QUIET=0 + +say() { ((QUIET)) || printf '%b\n' "$*"; } +ok() { ((QUIET)) || printf '%b\n' "${GREEN}$*${NC}"; } +warn() { ((QUIET)) || printf '%b\n' "${YELLOW}$*${NC}"; } +err() { printf '%b\n' "${RED}$*${NC}" >&2; } # Default behavior: QUIET VERBOSE=0 #─────────────────────────────────────────────────────────────────────────────── -# 0. GLOBAL ERROR HANDLER +# 0a. GLOBAL ERROR HANDLER #─────────────────────────────────────────────────────────────────────────────── command_not_found_handle() { local unknown="$1" @@ -84,86 +367,53 @@ command_not_found_handle() { trap 'on_error $? $LINENO "$BASH_COMMAND"' ERR on_error() { - printf "\n%bError:%b '%s' failed at line %d (exit %d)\n\n" \ - "$RED" "$NC" "$3" "$2" "$1" - exit "$1" -} + local code="$1" line="$2" cmd="$3" + local fn="${FUNCNAME[1]:-main}" + local src="${BASH_SOURCE[1]:-$0}" -############################################################################### -# 1. COMMON HELPERS -############################################################################### -die() { - printf "%bError:%b %s\n" "$RED" "$NC" "$*" - exit 1 -} + printf "\n%bError:%b %s:%s in %s() (exit %d)\n" "$RED" "$NC" "$src" "$line" "$fn" "$code" >&2 + printf "%bCommand:%b %s\n" "$RED" "$NC" "$cmd" >&2 -need() { - local group found cmd - for group in "$@"; do - IFS='|,' read -ra alts <<<"$group" - found=0 - for cmd in "${alts[@]}"; do - command -v "$cmd" &>/dev/null && { - found=1 - break - } + if ((VERBOSE)); then + printf "%bStack:%b\n" "$DIM" "$NC" >&2 + local i=1 + while caller "$i" >/dev/null 2>&1; do + caller "$i" >&2 + i=$((i + 1)) done - ((found)) && continue - local miss=${alts[*]} - miss=${miss// / or } - die "Missing command(s): $miss" - done + fi + printf "\n" >&2 + exit "$code" } -ensure_files_exist() { - local rel abs dir - for rel in "$@"; do - abs="${DIR}${rel}" - dir="${abs%/*}" - - if [[ ! -d $dir ]]; then - if mkdir -p "$dir" 2>/dev/null; then - printf "%b- Created directory %s%b\n" "$YELLOW" "$dir" "$NC" - else - printf "%b- Warning:%b cannot create directory %s (permissions?)\n" \ - "$YELLOW" "$NC" "$dir" - continue - fi - elif [[ ! -w $dir ]]; then - printf "%b- Warning:%b directory not writable: %s\n" "$YELLOW" "$NC" "$dir" - fi - - if [[ -e $abs ]]; then - [[ -w $abs ]] || printf "%b- Warning:%b file not writable: %s\n" "$YELLOW" "$NC" "$abs" - else - if : >"$abs" 2>/dev/null; then - printf "%b- Created file %s%b\n" "$YELLOW" "$abs" "$NC" - else - printf "%b- Error:%b cannot create file %s (permissions?)\n" "$RED" "$NC" "$abs" - fi - fi - done -} +############################################################################### +# 1a. DOCKER COMPOSE WRAPPER +############################################################################### # ── compose extras (docker/extras/*.y{a,}ml) ──────────────────────────────── __EXTRAS_LOADED=0 declare -a __EXTRA_FILES=() load_extras() { + # Set LDS_EXTRAS_RELOAD=1 (or global --reload-extras) to re-scan templates every call. + if [[ "${LDS_EXTRAS_RELOAD:-0}" == "1" ]]; then + __EXTRAS_LOADED=0 + fi + ((__EXTRAS_LOADED)) && return 0 __EXTRAS_LOADED=1 [[ -d "$EXTRAS_DIR" ]] || return 0 mapfile -t __EXTRA_FILES < <( - find "$EXTRAS_DIR" -maxdepth 1 -type f \( -name '*.yaml' -o -name '*.yml' \) -print 2>/dev/null \ - | sort \ - | sed '/^[[:space:]]*$/d' + find "$EXTRAS_DIR" -maxdepth 1 -type f \( -name '*.yaml' -o -name '*.yml' \) -print 2>/dev/null | sort | sed '/^[[:space:]]*$/d' ) } docker_compose() { load_extras + # Create required runtime files only when docker/compose operations are invoked. + ((EUID == 0)) || ensure_files_exist "/docker/.env" "/configuration/php/php.ini" "/.env" if [[ -z "${__LDS_DC_BIN:-}" ]]; then if docker compose version >/dev/null 2>&1; then __LDS_DC_BIN=(docker compose) @@ -177,50 +427,52 @@ docker_compose() { for f in "${__EXTRA_FILES[@]:-}"; do [[ -f "$f" ]] || continue case "$f" in - *.yml|*.yaml) extra_f+=(-f "$f") ;; + *.yml | *.yaml) extra_f+=(-f "$f") ;; esac done "${__LDS_DC_BIN[@]}" \ - --project-directory "$DIR" \ - -f "$COMPOSE_FILE" \ - "${extra_f[@]}" \ - --env-file "$ENV_DOCKER" \ - "$@" + --project-directory "$DIR" \ + -f "$COMPOSE_FILE" \ + "${extra_f[@]}" \ + --env-file "$ENV_DOCKER" \ + "$@" } # helper: print project name lds_project() { printf '%s' "${__LDS_PROJECT:-$(basename -- "$DIR")}"; } # (QUIET by default) ──────────────────────────────── -dc_up() { - if ((VERBOSE)); then - docker_compose up "$@" - else - docker_compose up --quiet-pull "$@" - fi -} +# Centralize quiet/verbose handling for compose subcommands. +# Usage: dc_cmd [args...] +dc_cmd() { + local sub="${1:-}" + shift || true -dc_pull() { - if ((VERBOSE)); then - docker_compose pull "$@" - else - docker_compose pull -q "$@" + local -a quiet=() + if ((VERBOSE == 0)); then + case "$sub" in + up) quiet+=(--quiet-pull) ;; + pull) quiet+=(-q) ;; + build) quiet+=(--quiet) ;; + esac fi -} -dc_build() { - if ((VERBOSE)); then - docker_compose build "$@" - else - docker_compose build --quiet "$@" - fi + docker_compose "$sub" "${quiet[@]}" "$@" } +dc_up() { dc_cmd up "$@"; } +dc_pull() { dc_cmd pull "$@"; } +dc_build() { dc_cmd build "$@"; } + # helper for our own minimal logging (still shows in quiet mode) logv() { ((VERBOSE)) && printf "%b[%s]%b %s\n" "$CYAN" "${1:-info}" "$NC" "${2:-}" >&2 || true; } logq() { printf "%b[%s]%b %s\n" "$CYAN" "${1:-info}" "$NC" "${2:-}" >&2; } +############################################################################### +# 1b. PROMPTS + DOTENV HELPERS +############################################################################### + # Unified prompt helper (used by env_init + profiles) tty_readline() { # Robust prompt/read across Linux/macOS/WSL/Windows Git Bash. @@ -315,6 +567,10 @@ update_env() { fi } +############################################################################### +# 1c. HTTP / WEB SERVER HELPERS +############################################################################### + http_reload() { printf "%bReloading HTTP...%b" "$MAGENTA" "$NC" docker ps -qf name=NGINX &>/dev/null && docker exec NGINX nginx -s reload &>/dev/null || true @@ -323,11 +579,11 @@ http_reload() { } ############################################################################### -# 2. PERMISSIONS FIX-UP +# 2. INSTALL / PERMISSIONS (HOST) ############################################################################### add_to_windows_path() { [[ "$OSTYPE" =~ (msys|cygwin) ]] || return 0 - command -v cygpath >/dev/null 2>&1 || return 0 + has_cmd cygpath || return 0 # Only add if lds.bat exists where we think it is [[ -f "$DIR/lds.bat" ]] || return 0 @@ -368,13 +624,6 @@ fix_perms() { chmod 755 "$DIR/docker" find "$DIR/docker" -type f ! -perm 644 -exec chmod 644 {} + - chmod 2777 "$DIR/data" - mkdir -p "$DIR/data/cloudbeaver" "$DIR/data/mysql" "$DIR/data/postgresql" "$DIR/data/mongo" \ - "$DIR/data/mariadb" "$DIR/data/elasticsearch" "$DIR/data/redis" "$DIR/data/mailpit" \ - "$DIR/data/redis-insight" "$DIR/data/kibana" "$DIR/data/filebeat" - find "$DIR/data" -mindepth 1 -maxdepth 1 -type d -exec chmod 2777 {} + - find "$DIR/data" -type f -exec chmod 666 {} + - chmod -R 777 "$DIR/logs" chown -R "$USER:docker" "$DIR/logs" @@ -387,34 +636,61 @@ fix_perms() { } ############################################################################### -# 3. DOMAIN & PROFILE UTILITIES +# 3. DOMAIN / PROFILE INTEGRATION ############################################################################### -mkhost() { docker exec SERVER_TOOLS mkhost "$@"; } -delhost() { docker exec SERVER_TOOLS delhost "$@"; } +mkhost() { + local ctr + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" + docker exec "$ctr" mkhost "$@" +} +rmhost() { + local ctr + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" + docker exec "$ctr" rmhost "$@" +} setup_domain() { + local ctr + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" + mkhost --RESET - docker exec -it SERVER_TOOLS mkhost - local php_prof svr_prof node_prof - php_prof=$(mkhost --ACTIVE_PHP_PROFILE || true) - svr_prof=$(mkhost --APACHE_ACTIVE || true) - node_prof=$(mkhost --ACTIVE_NODE_PROFILE || true) - [[ -n $php_prof ]] && modify_profiles add "$php_prof" + docker exec -it "$ctr" mkhost + local mk_state svr_prof + mk_state="$(mkhost --JSON || true)" + if has_tool jq; then + svr_prof="$(printf '%s' "$mk_state" | jq -r '.state.apache_active // empty' 2>/dev/null || true)" + else + svr_prof="$(printf '%s' "$mk_state" | tr -d '\r\n' | sed -n 's/.*"apache_active"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')" + fi [[ -n $svr_prof ]] && modify_profiles add "$svr_prof" - [[ -n $node_prof ]] && modify_profiles add "$node_prof" mkhost --RESET - dc_up -d - http_reload + cmd_reboot } -cmd_delhost() { - local domain="${1:-}" - [[ -n "$domain" ]] || die "Usage: lds delhost " +delete_domain() { + local ctr + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" - delhost "$domain" - delhost --RESET >/dev/null 2>&1 || true + rmhost --RESET - http_reload + # interactive delete + docker exec -it "$ctr" rmhost "$@" + + local rm_state apache_cont + rm_state="$(rmhost --JSON || true)" + if has_tool jq; then + apache_cont="$(printf '%s' "$rm_state" | jq -r '.state.apache_delete // empty' 2>/dev/null || true)" + else + apache_cont="$(printf '%s' "$rm_state" | tr -d '\r\n' | sed -n 's/.*"apache_delete"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')" + fi + [[ -n "$apache_cont" ]] && modify_profiles remove "$apache_cont" + + rmhost --RESET + cmd_reboot } modify_profiles() { @@ -457,6 +733,10 @@ modify_profiles() { # Profiles # ───────────────────────────────────────────────────────────────────────────── +############################################################################### +# 3a. PROFILES: DEFINITIONS + SETUP FLOW +############################################################################### + declare -A SERVICES=( [POSTGRESQL]="postgresql" [MYSQL]="mysql" @@ -622,114 +902,16 @@ process_all() { } ############################################################################### -# 4a. LAUNCH PHP CONTAINER INSIDE DOCROOT -############################################################################### -launch_php() { - local domain=$1 suffix - local nconf="$DIR/configuration/nginx/$domain.conf" - local aconf="$DIR/configuration/apache/$domain.conf" - [[ -f $nconf ]] || die "No Nginx config for $domain" - - local docroot php - if grep -q fastcgi_pass "$nconf"; then - php=$(grep -Eo 'fastcgi_pass ([^:]+):9000' "$nconf" | awk '{print $2}' | sed 's/:9000$//') - docroot=$(grep -m1 -Eo 'root [^;]+' "$nconf" | awk '{print $2}') - else - [[ -f $aconf ]] || die "No Apache config for $domain" - docroot=$(grep -m1 -Eo 'DocumentRoot [^ ]+' "$aconf" | awk '{print $2}') - php=$(grep -Eo 'proxy:fcgi://([^:]+):9000' "$aconf" | sed 's/.*:\/\/\([^:]*\):.*/\1/') - fi - - [[ $php ]] || die "Could not detect PHP container for $domain" - [[ $docroot ]] || docroot=/app - for suffix in public dist public_html; do - [[ $docroot == */$suffix ]] && { - docroot=${docroot%/*} - break - } - done - - php=$(echo "$php" | tr ' \n' '\n' | awk 'NF && !seen[$0]++' | paste -sd' ' -) - docker exec -it "$php" bash --login -c "cd '$docroot' && exec bash" -} - -############################################################################### -# 4b. LAUNCH NODE CONTAINER (always /app) -############################################################################### -launch_node() { - local domain="${1:-}" - [[ -n "$domain" ]] || die "Usage: lds core " - - local nconf="$DIR/configuration/nginx/$domain.conf" - [[ -f "$nconf" ]] || die "No Nginx config for $domain" - - # Expect: proxy_pass http://node_:; - local upstream host token ctr - upstream="$( - grep -m1 -Eo 'proxy_pass[[:space:]]+http://[^;]+' "$nconf" 2>/dev/null | - awk '{print $2}' | - sed 's|^http://||' - )" - - [[ -n "${upstream:-}" ]] || die "Could not detect node upstream for $domain" - host="${upstream%%:*}" # node_resume_sparkle_localhost - - [[ -n "${host:-}" ]] || die "Could not parse upstream host for $domain" - - # Standard mapping: node_ -> NODE_ - ctr="" - if docker inspect "$host" >/dev/null 2>&1; then - ctr="$host" - elif [[ "$host" == node_* ]]; then - token="${host#node_}" - ctr="NODE_${token^^}" - docker inspect "$ctr" >/dev/null 2>&1 || ctr="" - fi - - [[ -n "${ctr:-}" ]] || die "Node container not found for upstream '$host' (domain: $domain)" - docker inspect -f '{{.State.Running}}' "$ctr" 2>/dev/null | grep -qx true || die "Container not running: $ctr" - - docker exec -it "$ctr" sh -lc ' - cd /app 2>/dev/null || cd / || true - if command -v bash >/dev/null 2>&1; then exec bash --login; fi - exec sh - ' -} - -conf_node_container() { - local f="$1" - - # Nginx node vhost: proxy_pass http://node_:; - local host token ctr - - host="$( - grep -m1 -Eo 'proxy_pass[[:space:]]+http://[^;]+' "$f" 2>/dev/null | - awk '{print $2}' | - sed 's|^http://||' | - awk -F: '{print $1}' - )" - - [[ -n "${host:-}" ]] || return 0 - [[ "$host" == node_* ]] || return 0 - - token="${host#node_}" - ctr="NODE_${token^^}" - - docker inspect "$ctr" >/dev/null 2>&1 || return 0 - printf '%s' "$ctr" -} - -############################################################################### -# 5. ENV + CERT +# 5. ENVIRONMENT + CERT / CA ############################################################################### detect_timezone() { - if command -v timedatectl &>/dev/null; then + if has_cmd timedatectl; then timedatectl show -p Timezone --value elif [[ -n ${TZ-} ]]; then printf '%s' "$TZ" elif [[ -r /etc/timezone ]]; then /dev/null; then + elif has_cmd powershell.exe; then powershell.exe -NoProfile -Command "[System.TimeZoneInfo]::Local.Id" 2>/dev/null | tr -d '\r' else date +%Z @@ -783,7 +965,7 @@ detect_os_family() { . /etc/os-release || true id="${ID:-unknown}" like="${ID_LIKE:-unknown}" - elif command -v uname >/dev/null 2>&1; then + elif has_cmd uname; then # fallback for macOS / other unix case "$(uname -s 2>/dev/null || true)" in Darwin) @@ -830,14 +1012,14 @@ is_windows_shell() { } need_windows_tools() { - command -v cygpath >/dev/null 2>&1 || die "Windows certificate install needs 'cygpath' (Git Bash)." - command -v powershell.exe >/dev/null 2>&1 || die "Windows certificate install needs 'powershell.exe' on PATH." + has_cmd cygpath || die "Windows certificate install needs 'cygpath' (Git Bash)." + has_cmd powershell.exe || die "Windows certificate install needs 'powershell.exe' on PATH." } # Import CA into the invoking user's NSS DB (Chrome/Chromium/Firefox on many Linux setups) install_ca_nss_user() { local ca_file="$1" - command -v certutil >/dev/null 2>&1 || return 0 + has_cmd certutil || return 0 local user="${SUDO_USER:-}" [[ -n "$user" && "$user" != "root" ]] || return 0 @@ -914,7 +1096,7 @@ install_ca() { case "$family" in debian | alpine) - if command -v update-ca-certificates >/dev/null 2>&1; then + if has_cmd update-ca-certificates; then printf "%bUpdating trust store%b (update-ca-certificates)…\n" "$CYAN" "$NC" if update-ca-certificates; then printf "%b✔ Trust store updated%b\n" "$GREEN" "$NC" @@ -927,7 +1109,7 @@ install_ca() { fi # Optional p11-kit sync: best-effort only (can be missing helper on minimal installs) - if command -v trust >/dev/null 2>&1; then + if has_cmd trust; then printf "%bSyncing p11-kit%b (trust extract-compat)…\n" "$CYAN" "$NC" if trust extract-compat >/dev/null 2>&1; then printf "%b✔ p11-kit trust synced%b\n" "$GREEN" "$NC" @@ -939,7 +1121,7 @@ install_ca() { fi ;; rhel) - if command -v update-ca-trust >/dev/null 2>&1; then + if has_cmd update-ca-trust; then printf "%bUpdating trust store%b (update-ca-trust extract)…\n" "$CYAN" "$NC" if update-ca-trust extract; then printf "%b✔ Trust store updated%b\n" "$GREEN" "$NC" @@ -951,7 +1133,7 @@ install_ca() { fi ;; arch) - if command -v trust >/dev/null 2>&1; then + if has_cmd trust; then printf "%bUpdating trust store%b (trust extract-compat)…\n" "$CYAN" "$NC" if trust extract-compat >/dev/null 2>&1; then printf "%b✔ Trust store updated%b\n" "$GREEN" "$NC" @@ -1011,7 +1193,7 @@ uninstall_ca_windows() { } uninstall_ca_nss_user() { - command -v certutil >/dev/null 2>&1 || return 0 + has_cmd certutil || return 0 local user="${SUDO_USER:-}" [[ -n "$user" && "$user" != "root" ]] || return 0 @@ -1078,20 +1260,20 @@ uninstall_ca() { case "$family" in debian | alpine) - if command -v update-ca-certificates >/dev/null 2>&1; then + if has_cmd update-ca-certificates; then printf "%bUpdating trust store%b (update-ca-certificates)…\n" "$CYAN" "$NC" update-ca-certificates || printf "%bWARN%b: update-ca-certificates failed.\n" "$YELLOW" "$NC" >&2 else printf "%bWARN%b: update-ca-certificates not found; trust store not refreshed.\n" "$YELLOW" "$NC" >&2 fi - if command -v trust >/dev/null 2>&1; then + if has_cmd trust; then printf "%bSyncing p11-kit%b (trust extract-compat)…\n" "$CYAN" "$NC" trust extract-compat >/dev/null 2>&1 || printf "%bWARN%b: trust extract-compat failed. Skipping.\n" "$YELLOW" "$NC" >&2 fi ;; rhel) - if command -v update-ca-trust >/dev/null 2>&1; then + if has_cmd update-ca-trust; then printf "%bUpdating trust store%b (update-ca-trust extract)…\n" "$CYAN" "$NC" update-ca-trust extract || printf "%bWARN%b: update-ca-trust extract failed.\n" "$YELLOW" "$NC" >&2 else @@ -1099,7 +1281,7 @@ uninstall_ca() { fi ;; arch) - if command -v trust >/dev/null 2>&1; then + if has_cmd trust; then printf "%bUpdating trust store%b (trust extract-compat)…\n" "$CYAN" "$NC" trust extract-compat >/dev/null 2>&1 || printf "%bWARN%b: trust extract-compat failed.\n" "$YELLOW" "$NC" >&2 else @@ -1107,15 +1289,15 @@ uninstall_ca() { fi ;; *) - if command -v update-ca-certificates >/dev/null 2>&1; then + if has_cmd update-ca-certificates; then printf "%bUpdating trust store%b (update-ca-certificates)…\n" "$CYAN" "$NC" update-ca-certificates || true fi - if command -v update-ca-trust >/dev/null 2>&1; then + if has_cmd update-ca-trust; then printf "%bUpdating trust store%b (update-ca-trust extract)…\n" "$CYAN" "$NC" update-ca-trust extract || true fi - if command -v trust >/dev/null 2>&1; then + if has_cmd trust; then printf "%bSyncing p11-kit%b (trust extract-compat)…\n" "$CYAN" "$NC" trust extract-compat >/dev/null 2>&1 || true fi @@ -1212,19 +1394,10 @@ compose_has_build() { local svc="$1" json json="$(compose_cfg_json)" if [[ -n "$json" ]]; then - if command -v jq >/dev/null 2>&1; then + if has_tool jq; then jq -e --arg s "$svc" '.services[$s].build != null' >/dev/null <<<"$json" return $? fi - if command -v python3 >/dev/null 2>&1; then - COMPOSE_CFG_JSON="$json" python3 - "$svc" <<'PY' -import json, os, sys -svc = sys.argv[1] -cfg = json.loads(os.environ.get("COMPOSE_CFG_JSON", "") or "{}") -sys.exit(0 if cfg.get("services", {}).get(svc, {}).get("build") is not None else 1) -PY - return $? - fi fi compose_cfg_yaml | awk -v s="$svc" ' @@ -1240,19 +1413,10 @@ compose_image_for_service() { local svc="$1" json json="$(compose_cfg_json)" if [[ -n "$json" ]]; then - if command -v jq >/dev/null 2>&1; then + if has_tool jq; then jq -r --arg s "$svc" '.services[$s].image // empty' <<<"$json" return 0 fi - if command -v python3 >/dev/null 2>&1; then - COMPOSE_CFG_JSON="$json" python3 - "$svc" <<'PY' -import json, os, sys -svc = sys.argv[1] -cfg = json.loads(os.environ.get("COMPOSE_CFG_JSON", "") or "{}") -print(cfg.get("services", {}).get(svc, {}).get("image", "") or "") -PY - return 0 - fi fi compose_cfg_yaml | awk -v s="$svc" ' @@ -1268,7 +1432,7 @@ PY } ############################################################################### -# 6. COMMANDS +# 6. STACK COMMANDS (CLI) ############################################################################### cmd_up() { dc_up "$@"; } @@ -1277,8 +1441,6 @@ cmd_start() { http_reload } -cmd_reload() { cmd_start "$@"; } - cmd_stop() { docker_compose down; } cmd_down() { @@ -1288,13 +1450,26 @@ cmd_down() { local -a args=() while [[ "${1:-}" ]]; do case "$1" in - --yes|-y) yes=1; shift ;; - --volumes|-v) vols=1; args+=("--volumes"); shift ;; - --remove-orphans) args+=("--remove-orphans"); shift ;; - *) args+=("$1"); shift ;; + --yes | -y) + yes=1 + shift + ;; + --volumes | -v) + vols=1 + args+=("--volumes") + shift + ;; + --remove-orphans) + args+=("--remove-orphans") + shift + ;; + *) + args+=("$1") + shift + ;; esac done - if ((vols)) && ((yes==0)); then + if ((vols)) && ((yes == 0)); then die "Refusing: down --volumes requires --yes" fi docker_compose down "${args[@]}" @@ -1310,281 +1485,23 @@ cmd_reboot() { cmd_restart; } # 6a. STATUS / PS / STATS # ───────────────────────────────────────────────────────────────────────────── cmd_ps() { - if (( $# )); then + if (($#)); then docker_compose ps "$@" else docker_compose ps fi } -cmd_stats() { - local svc="${1:-}" - local project; project="$(lds_project)" - if [[ -n "$svc" ]]; then - # accept service or container name - local s; s="$(resolve_service "$svc" || true)" - if [[ -n "$s" ]]; then - docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}' --filter "name=${project}_${s}" - return 0 - fi - fi - docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.NetIO}}\t{{.BlockIO}}' --filter "label=com.docker.compose.project=$project" -} - -_status_urls() { - local f d - shopt -s nullglob - for f in "$DIR/configuration/nginx/"*.conf; do - d="$(basename -- "$f" .conf)" - [[ -n "$d" ]] && printf 'https://%s\n' "$d" - done - shopt -u nullglob -} - -_status_health_line_plain() { - local c="$1" - local st health - st="$(docker inspect -f '{{.State.Status}}' "$c" 2>/dev/null || true)" - health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$c" 2>/dev/null || true)" - if [[ -n "$health" ]]; then - printf '%s:%s(%s)' "$c" "$st" "$health" - else - printf '%s:%s' "$c" "$st" - fi -} - -_status_health_line() { - # Colorized for human output (mkhost-style theme) - local c="$1" - local st health line - st="$(docker inspect -f '{{.State.Status}}' "$c" 2>/dev/null || true)" - health="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{end}}' "$c" 2>/dev/null || true)" - line="$(_status_health_line_plain "$c")" - - # Prefer health when present, otherwise State.Status - if [[ -n "$health" ]]; then - case "$health" in - healthy) printf '%b%s%b' "$GREEN" "$line" "$NC" ;; - starting) printf '%b%s%b' "$YELLOW" "$line" "$NC" ;; - unhealthy) printf '%b%s%b' "$RED" "$line" "$NC" ;; - *) printf '%b%s%b' "$MAGENTA" "$line" "$NC" ;; - esac - else - case "$st" in - running) printf '%b%s%b' "$GREEN" "$line" "$NC" ;; - exited|dead) printf '%b%s%b' "$RED" "$line" "$NC" ;; - created|paused|restarting) printf '%b%s%b' "$YELLOW" "$line" "$NC" ;; - *) printf '%b%s%b' "$MAGENTA" "$line" "$NC" ;; - esac - fi -} - cmd_status() { - local json=0 quiet=0 - while [[ "${1:-}" ]]; do - case "$1" in - --json) json=1; shift ;; - --quiet|-q) quiet=1; shift ;; - *) break ;; - esac - done - - local project; project="$(lds_project)" - - local profiles="" - if [[ -r "$ENV_DOCKER" ]]; then - profiles="$(grep -E '^[[:space:]]*COMPOSE_PROFILES=' "$ENV_DOCKER" | tail -n1 | cut -d= -f2- | tr -d '\r' || true)" - fi + local ctr project + project="$(lds_project)" + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || die "server-tools container not found or not running for project: $project" - # containers (Name \t Service \t State \t Health) - local -a ctrs=() - mapfile -t ctrs < <( - docker_compose ps -a --format '{{.Name}}\t{{.Service}}\t{{.State}}\t{{.Health}}' 2>/dev/null \ - | sed '/^[[:space:]]*$/d' - ) - - # urls - local -a urls=() - mapfile -t urls < <(_status_urls 2>/dev/null || true) - - # ---- helpers ---- - _json_escape() { - local s="$1" - s="${s//\\/\\\\}" - s="${s//\"/\\\"}" - s="${s//$'\n'/\\n}" - s="${s//$'\r'/\\r}" - s="${s//$'\t'/\\t}" - printf '%s' "$s" - } - - _health_color() { - local h="$1" - # avoid ${var,,} to stay compatible - if [[ -z "$h" || "$h" == "null" ]]; then - printf '%s' "$DIM" - elif [[ "$h" == *"healthy"* ]]; then - printf '%s' "$GREEN" - elif [[ "$h" == *"unhealthy"* ]]; then - printf '%s' "$RED" - else - printf '%s' "$YELLOW" - fi - } - - _state_color() { - local s="$1" - case "$s" in - running) printf '%s' "$GREEN" ;; - exited|dead) printf '%s' "$RED" ;; - restarting) printf '%s' "$YELLOW" ;; - *) printf '%s' "$DIM" ;; - esac - } - - # NEW: health icon (✓, !, ×) - safe + simple - _health_icon() { - local h="$1" - if [[ -z "$h" || "$h" == "null" || "$h" == "-" ]]; then - printf '!' - elif [[ "$h" == *"unhealthy"* ]]; then - printf '×' - elif [[ "$h" == *"healthy"* ]]; then - printf '✓' - else - printf '!' - fi - } - - # NEW: running/total summary - local total running - total=${#ctrs[@]} - running=0 - if (( total )); then - local line _n _svc st _h - for line in "${ctrs[@]}"; do - IFS=$'\t' read -r _n _svc st _h <<<"$line" - [[ "$st" == "running" ]] && ((++running)) - done - fi - - # ---- JSON mode ---- - if (( json )); then - printf '{' - printf '"project":"%s",' "$(_json_escape "$project")" - printf '"profiles":"%s",' "$(_json_escape "$profiles")" - printf '"summary":{"running":%s,"total":%s},' "$running" "$total" - - printf '"containers":[' - local first=1 line name svc state health - for line in "${ctrs[@]}"; do - IFS=$'\t' read -r name svc state health <<<"$line" - (( first )) || printf ',' - first=0 - - local health_disp="${health:-}" - [[ -z "$health_disp" || "$health_disp" == "null" ]] && health_disp="-" - - printf '{' - printf '"name":"%s",' "$(_json_escape "$name")" - printf '"service":"%s",' "$(_json_escape "$svc")" - printf '"state":"%s",' "$(_json_escape "$state")" - printf '"health":"%s",' "$(_json_escape "$health_disp")" - printf '"health_icon":"%s"' "$(_json_escape "$(_health_icon "$health_disp")")" - printf '}' - done - printf '],' - - printf '"ports":[80,443],' - - printf '"urls":[' - first=1 - local u - for u in "${urls[@]}"; do - [[ -n "$u" ]] || continue - (( first )) || printf ',' - first=0 - printf '"%s"' "$(_json_escape "$u")" - done - printf ']' - - printf '}\n' - return 0 - fi - - # ---- Quiet mode ---- - if (( quiet )); then - return 0 - fi - - # ---- Pretty output ---- - printf "%bProject:%b %s\n" "$CYAN" "$NC" "$project" - [[ -n "$profiles" ]] && printf "%bProfiles:%b %s\n" "$CYAN" "$NC" "$profiles" - - # UPDATED: Containers line shows running/total summary - printf "%bContainers:%b %b(%s/%s running)%b\n" "$CYAN" "$NC" "$DIM" "$running" "$total" "$NC" - - if ((${#ctrs[@]} == 0)); then - printf " (none)\n" - else - # column widths - local w_name=4 w_svc=7 - local line name svc state health - for line in "${ctrs[@]}"; do - IFS=$'\t' read -r name svc state health <<<"$line" - ((${#name} > w_name)) && w_name=${#name} - ((${#svc} > w_svc)) && w_svc=${#svc} - done - (( w_name > 34 )) && w_name=34 - (( w_svc > 18 )) && w_svc=18 - - # header - printf " %b%-*s%b %b%-*s%b %b%-10s%b %b%s%b\n" \ - "$BOLD" "$w_name" "NAME" "$NC" \ - "$BOLD" "$w_svc" "SERVICE" "$NC" \ - "$BOLD" "STATE" "$NC" \ - "$BOLD" "HEALTH" "$NC" - - # rows - for line in "${ctrs[@]}"; do - IFS=$'\t' read -r name svc state health <<<"$line" - - local name_disp="$name" svc_disp="$svc" - if ((${#name_disp} > w_name)); then name_disp="${name_disp:0:w_name-1}…"; fi - if ((${#svc_disp} > w_svc)); then svc_disp="${svc_disp:0:w_svc-1}…"; fi - - local stc hc - stc="$(_state_color "$state")" - hc="$(_health_color "${health:-}")" - - local health_disp="${health:-}" - [[ -z "$health_disp" || "$health_disp" == "null" ]] && health_disp="-" - - # NEW: icon prefix - local hi; hi="$(_health_icon "$health_disp")" - - printf " %-*s %-*s %b%-10s%b %b%s %s%b\n" \ - "$w_name" "$name_disp" \ - "$w_svc" "$svc_disp" \ - "$stc" "${state:-"-"}" "$NC" \ - "$hc" "$hi" "$health_disp" "$NC" - done - fi - - printf "%bPorts:%b 80, 443\n" "$CYAN" "$NC" - - printf "%bURLs:%b\n" "$CYAN" "$NC" - if ((${#urls[@]})); then - local u - for u in "${urls[@]}"; do - [[ -n "$u" ]] || continue - printf " - %b%s%b\n" "$BLUE" "$u" "$NC" - done - else - printf " (none)\n" - fi + local -a flags=() + [[ -t 1 ]] && flags+=(-t) + docker exec "${flags[@]}" "$ctr" status "$@" } - # ───────────────────────────────────────────────────────────────────────────── # 6b. LOGS / OPEN # ───────────────────────────────────────────────────────────────────────────── @@ -1592,10 +1509,22 @@ cmd_logs() { local svc="" follow=0 since="" grep_pat="" while [[ "${1:-}" ]]; do case "$1" in - -f|--follow) follow=1; shift ;; - --since) since="${2:-}"; shift 2 ;; - --grep) grep_pat="${2:-}"; shift 2 ;; - *) svc="${1:-}"; shift ;; + -f | --follow) + follow=1 + shift + ;; + --since) + since="${2:-}" + shift 2 + ;; + --grep) + grep_pat="${2:-}" + shift 2 + ;; + *) + svc="${1:-}" + shift + ;; esac done @@ -1604,16 +1533,17 @@ cmd_logs() { [[ -n "$since" ]] && args+=("--since" "$since") if [[ -n "$svc" ]]; then - local s; s="$(resolve_service "$svc" || true)" + local s + s="$(resolve_service "$svc" || true)" [[ -n "$s" ]] || die "Unknown service: $svc" if [[ -n "$grep_pat" ]]; then - docker_compose logs "${args[@]}" "$s" 2>&1 | rg -n -- "$grep_pat" + docker_compose logs "${args[@]}" "$s" 2>&1 | text_grep "$grep_pat" else docker_compose logs "${args[@]}" "$s" fi else if [[ -n "$grep_pat" ]]; then - docker_compose logs "${args[@]}" 2>&1 | rg -n -- "$grep_pat" + docker_compose logs "${args[@]}" 2>&1 | text_grep "$grep_pat" else docker_compose logs "${args[@]}" fi @@ -1625,14 +1555,14 @@ cmd_open() { [[ -n "$target" ]] || die "open " local url="" case "${target,,}" in - mail|mailpit|webmail) url="https://webmail.localhost" ;; - db|cloudbeaver) url="https://db.localhost" ;; - redis|redisinsight|redis-insight|rds) url="http://ri.localhost" ;; - mongo|me|mongoexpress|mongo-express) url="http://me.localhost" ;; - kibana|kbn) url="http://kibana.localhost" ;; - *) - url="https://${target}" - ;; + mail | mailpit | webmail) url="https://webmail.localhost" ;; + db | cloudbeaver) url="https://db.localhost" ;; + redis | redisinsight | redis-insight | rds) url="http://ri.localhost" ;; + mongo | me | mongoexpress | mongo-express) url="http://me.localhost" ;; + kibana | kbn) url="http://kibana.localhost" ;; + *) + url="https://${target}" + ;; esac open_url "$url" } @@ -1643,17 +1573,22 @@ cmd_open() { _known_profile() { local p="${1:-}" [[ -n "$p" ]] || return 1 - # quick/cheap check across compose files + + # Prefer compose-config JSON for exact profile membership. + local json + json="$(compose_cfg_json)" + if [[ -n "$json" ]] && has_tool jq; then + printf '%s' "$json" | jq -e --arg p "$p" ' + [ .services[]? | (.profiles // [])[] ] | index($p) != null + ' >/dev/null 2>&1 + return $? + fi + + # Fallback: text scan when jq/json path is unavailable. local f for f in "$COMPOSE_FILE" "${__EXTRA_FILES[@]:-}"; do [[ -r "$f" ]] || continue - if grep -Eq "profiles:[[:space:]]*\[.*\b${p}\b.*\]" "$f" || grep -Eq "profiles:[[:space:]]*$" "$f"; then - # fall through to deeper check with rg if present - : - fi - if grep -Eq "\b${p}\b" "$f"; then - return 0 - fi + grep -Fq -- "$p" "$f" && return 0 done return 1 } @@ -1662,40 +1597,40 @@ cmd_profiles() { local action="${1:-list}" shift || true case "${action,,}" in - list|"") - local cur="" - [[ -r "$ENV_DOCKER" ]] && cur="$(grep -E '^COMPOSE_PROFILES=' "$ENV_DOCKER" | tail -n1 | cut -d= -f2- | tr -d '\r' || true)" - printf "%bEnabled profiles:%b %s + list | "") + local cur="" + [[ -r "$ENV_DOCKER" ]] && cur="$(grep -E '^COMPOSE_PROFILES=' "$ENV_DOCKER" | tail -n1 | cut -d= -f2- | tr -d '\r' || true)" + printf "%bEnabled profiles:%b %s " "$CYAN" "$NC" "${cur:-}" - printf "%bAvailable profiles:%b + printf "%bAvailable profiles:%b " "$CYAN" "$NC" - printf ' - %s + printf ' - %s ' "${SERVICES[@]}" | LC_ALL=C sort -u - # warn if enabled profile has no mention in compose - if [[ -n "$cur" ]]; then - local p - IFS=',' read -r -a __ps <<<"$cur" - for p in "${__ps[@]}"; do - p="${p//[[:space:]]/}" - [[ -n "$p" ]] || continue - _known_profile "$p" || printf "%b[warn]%b enabled profile '%s' has no matching services in compose + # warn if enabled profile has no mention in compose + if [[ -n "$cur" ]]; then + local p + IFS=',' read -r -a __ps <<<"$cur" + for p in "${__ps[@]}"; do + p="${p//[[:space:]]/}" + [[ -n "$p" ]] || continue + _known_profile "$p" || printf "%b[warn]%b enabled profile '%s' has no matching services in compose " "$YELLOW" "$NC" "$p" - done - fi - ;; - add) - [[ $# -gt 0 ]] || die "profiles add " - for p in "$@"; do - modify_profiles add "$p" done - ;; - remove|rm|del) - [[ $# -gt 0 ]] || die "profiles remove " - modify_profiles remove "$@" - ;; - *) - die "profiles " - ;; + fi + ;; + add) + [[ $# -gt 0 ]] || die "profiles add " + for p in "$@"; do + modify_profiles add "$p" + done + ;; + remove | rm | del) + [[ $# -gt 0 ]] || die "profiles remove " + modify_profiles remove "$@" + ;; + *) + die "profiles " + ;; esac } @@ -1703,10 +1638,11 @@ cmd_profiles() { # 6d. DIAG / SNIFF # ───────────────────────────────────────────────────────────────────────────── _tools_exec() { - docker inspect SERVER_TOOLS >/dev/null 2>&1 || die "SERVER_TOOLS container not found" - docker inspect -f '{{.State.Running}}' SERVER_TOOLS 2>/dev/null | grep -qx true || die "SERVER_TOOLS is not running" + local ctr + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" # NOTE: pass a SINGLE command string; do not pass arrays here. - docker exec -i SERVER_TOOLS sh -lc "$*" + docker exec -i "$ctr" sh -lc "$*" } _shq() { printf '%q' "$1"; } @@ -1716,41 +1652,47 @@ cmd_diag() { shift || true case "${sub,,}" in - dns) - local dom="${1:-}"; [[ -n "$dom" ]] || die "diag dns " - local qdom; qdom="$(_shq "$dom")" - _tools_exec "dig +short $qdom; echo; nslookup $qdom 2>/dev/null || true; echo; getent hosts $qdom 2>/dev/null || true" - ;; - route|net) - _tools_exec "ip r; echo; ip a; echo; ss -tulpen 2>/dev/null || netstat -tulpen 2>/dev/null || true" - ;; - tcp) - local h="${1:-}"; local p="${2:-}" - [[ -n "$h" && -n "$p" ]] || die "diag tcp " - _tools_exec "nc -vz -w2 $(_shq "$h") $(_shq "$p")" - ;; - http) - local url="${1:-}"; shift || true - [[ -n "$url" ]] || die "diag http [curl-args...]" - local -a qargs=() - local a - for a in "$@"; do qargs+=("$(printf '%q' "$a")"); done - _tools_exec "curl -vkI $(_shq "$url") ${qargs[*]}" - ;; - tls) - local dom="${1:-}"; [[ -n "$dom" ]] || die "diag tls " - local qdom; qdom="$(_shq "$dom")" - _tools_exec "echo | openssl s_client -connect ${qdom}:443 -servername $qdom -showcerts 2>/dev/null | sed -n '1,60p'" - ;; - *) - die "diag " - ;; + dns) + local dom="${1:-}" + [[ -n "$dom" ]] || die "diag dns " + local qdom + qdom="$(_shq "$dom")" + _tools_exec "dig +short $qdom; echo; nslookup $qdom 2>/dev/null || true; echo; getent hosts $qdom 2>/dev/null || true" + ;; + route | net) + _tools_exec "ip r; echo; ip a; echo; ss -tulpen 2>/dev/null || netstat -tulpen 2>/dev/null || true" + ;; + tcp) + local h="${1:-}" + local p="${2:-}" + [[ -n "$h" && -n "$p" ]] || die "diag tcp " + _tools_exec "nc -vz -w2 $(_shq "$h") $(_shq "$p")" + ;; + http) + local url="${1:-}" + shift || true + [[ -n "$url" ]] || die "diag http [curl-args...]" + local -a qargs=() + local a + for a in "$@"; do qargs+=("$(printf '%q' "$a")"); done + _tools_exec "curl -vkI $(_shq "$url") ${qargs[*]}" + ;; + tls) + local dom="${1:-}" + [[ -n "$dom" ]] || die "diag tls " + local qdom + qdom="$(_shq "$dom")" + _tools_exec "echo | openssl s_client -connect ${qdom}:443 -servername $qdom -showcerts 2>/dev/null | sed -n '1,60p'" + ;; + *) + die "diag " + ;; esac } - cmd_sniff() { - local url="${1:-}"; shift || true + local url="${1:-}" + shift || true [[ -n "$url" ]] || die "sniff [curl-args...]" local -a qargs=() local a @@ -1759,99 +1701,74 @@ cmd_sniff() { } # ───────────────────────────────────────────────────────────────────────────── -# 6e. SECRETS / CERT / HOST / UI / RUNTIME +# 6e. SECRETS / CERT / HOST / UI # ───────────────────────────────────────────────────────────────────────────── -cmd_secrets() { docker exec -it SERVER_TOOLS senv "$@"; } +cmd_secrets() { + local ctr + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" + docker exec -it "$ctr" senv "$@" +} -cmd_cert() { docker exec -it SERVER_TOOLS certify "$@"; } +cmd_cert() { + local ctr + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" + docker exec -it "$ctr" certify "$@" +} cmd_host() { - local sub="${1:-}"; shift || true + local sub="${1:-}" + shift || true case "${sub,,}" in - add) - setup_domain - ;; - rm|remove|del|delete) - local dom="${1:-}"; [[ -n "$dom" ]] || die "host rm " - cmd_delhost "$dom" - ;; - list) - shopt -s nullglob - for f in "$DIR/configuration/nginx/"*.conf; do - printf '%s + add) + setup_domain + ;; + rm | remove | del | delete) + delete_domain "$@" + ;; + list) + shopt -s nullglob + for f in "$DIR/configuration/nginx/"*.conf; do + printf '%s ' "$(basename -- "$f" .conf)" - done - shopt -u nullglob - ;; - *) - die "host " - ;; + done + shopt -u nullglob + ;; + *) + die "host " + ;; esac } -cmd_ui() { cmd_lzd; } - -cmd_runtime() { - local which="${1:-}"; [[ -n "$which" ]] || die "runtime " - local f="" - for f in "$DIR/runtime-versions.json" "$DIR/docker/runtime-versions.json" "$DIR/configuration/runtime-versions.json"; do - [[ -r "$f" ]] && break || f="" - done - if [[ -z "$f" ]]; then - printf "%b[warn]%b runtime-versions.json not found -" "$YELLOW" "$NC" - return 0 - fi - _tools_exec "jq -r '."${which}" // empty' "$f" 2>/dev/null || cat "$f"" +cmd_ui() { + local ctr + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" + docker exec -it "$ctr" lazydocker } # ───────────────────────────────────────────────────────────────────────────── -# 6f. EXEC / CHECK / EVENTS / CLEAN / ENV / VERIFY / DISK +# 6f. EXEC / EVENTS / CLEAN / DISK # ───────────────────────────────────────────────────────────────────────────── cmd_exec() { - local svc="${1:-}"; shift || true + local svc="${1:-}" + shift || true [[ -n "$svc" ]] || die "exec [cmd...]" - local s; s="$(resolve_service "$svc" || true)" + local s + s="$(resolve_service "$svc" || true)" [[ -n "$s" ]] || die "Unknown service: $svc" if [[ $# -gt 0 ]]; then - docker_compose exec "$s" "$@" - else - docker_compose exec "$s" sh -lc 'command -v bash >/dev/null 2>&1 && exec bash || exec sh' - fi -} - -cmd_check() { - local sub="${1:-}"; shift || true - case "${sub,,}" in - upstream) - local dom="${1:-}"; [[ -n "$dom" ]] || die "check upstream " - local nconf="$DIR/configuration/nginx/$dom.conf" - [[ -r "$nconf" ]] || die "No nginx conf for domain: $dom" - printf "%bDomain:%b %s -" "$CYAN" "$NC" "$dom" - if grep -q fastcgi_pass "$nconf"; then - local php; php="$(grep -Eo 'fastcgi_pass ([^:]+):9000' "$nconf" | awk '{print $2}' | sed 's/:9000$//' | head -n1 || true)" - printf "Upstream (php): %s -" "${php:-unknown}" - [[ -n "$php" ]] && _tools_exec "nc -vz -w2 "$php" 9000 || true" - elif grep -q proxy_pass "$nconf"; then - local up; up="$(grep -m1 -Eo 'proxy_pass[[:space:]]+http://[^;]+' "$nconf" | awk '{print $2}' | sed 's|^http://||' || true)" - printf "Upstream (http): %s -" "${up:-unknown}" - local h="${up%%:*}" p="${up##*:}" - [[ -n "$h" && -n "$p" && "$h" != "$up" ]] && _tools_exec "nc -vz -w2 "$h" "$p" || true" - fi - _tools_exec "curl -vkI "https://$dom" || true" - ;; - *) - die "check upstream " - ;; - esac + docker_compose exec "$s" "$@" + else + docker_compose exec "$s" sh -lc 'command -v bash >/dev/null 2>&1 && exec bash || exec sh' + fi } cmd_events() { local since="${1:-1h}" - local project; project="$(lds_project)" + local project + project="$(lds_project)" docker events --since "$since" --filter "label=com.docker.compose.project=$project" } @@ -1859,64 +1776,44 @@ cmd_clean() { local yes=0 vols=0 while [[ "${1:-}" ]]; do case "$1" in - --yes|-y) yes=1; shift ;; - --volumes|-v) vols=1; shift ;; - *) shift ;; + --yes | -y) + yes=1 + shift + ;; + --volumes | -v) + vols=1 + shift + ;; + *) + die "clean [--yes|-y] [--volumes|-v]" + ;; esac done + ((yes)) || die "clean requires --yes" - local project; project="$(lds_project)" - # remove stopped containers for this project - docker rm -f $(docker ps -a --filter "label=com.docker.compose.project=$project" --filter "status=exited" -q) 2>/dev/null || true - if ((vols)); then - docker volume rm $(docker volume ls -q --filter "label=com.docker.compose.project=$project") 2>/dev/null || true - fi - printf "%b[clean]%b done -" "$GREEN" "$NC" -} -cmd_env() { docker exec -it SERVER_TOOLS senv env "$@"; } + printf "%b[clean]%b pruning stopped containers...\n" "$CYAN" "$NC" + docker container prune -f >/dev/null 2>&1 || true -cmd_verify() { - # smoke checks: compose config, LB up, curl each domain - docker_compose config >/dev/null - cmd_status --quiet >/dev/null || true - local d - while IFS= read -r d; do - [[ -n "$d" ]] || continue - _tools_exec "curl -skI "https://$d" | head -n 1" - done < <(shopt -s nullglob; for f in "$DIR/configuration/nginx/"*.conf; do basename -- "$f" .conf; done; shopt -u nullglob) -} + printf "%b[clean]%b pruning unused networks...\n" "$CYAN" "$NC" + docker network prune -f >/dev/null 2>&1 || true -cmd_disk() { - docker system df - printf " -%bProject data:%b -" "$CYAN" "$NC" - du -sh "$DIR/data" 2>/dev/null || true -} + printf "%b[clean]%b pruning unused images...\n" "$CYAN" "$NC" + docker image prune -a -f >/dev/null 2>&1 || true -cmd_du() { cmd_disk; } + printf "%b[clean]%b pruning build cache...\n" "$CYAN" "$NC" + docker builder prune -a -f >/dev/null 2>&1 || true -# ───────────────────────────────────────────────────────────────────────────── -# 6g. NGINX INTROSPECTION -# ───────────────────────────────────────────────────────────────────────────── -cmd_nginx() { - local dom="${1:-}" - [[ -n "$dom" ]] || die "nginx " - local f="$DIR/configuration/nginx/$dom.conf" - [[ -r "$f" ]] || die "No nginx conf: $f" - printf "%bNginx vhost:%b %s -" "$CYAN" "$NC" "$f" - grep -nE 'server_name|listen|root |proxy_pass|fastcgi_pass|include |error_page' "$f" || true - if grep -q '/etc/nginx/html' "$f"; then - printf "%b[warn]%b mentions /etc/nginx/html (default root fallback risk) -" "$YELLOW" "$NC" + if ((vols)); then + printf "%b[clean]%b pruning unused volumes...\n" "$CYAN" "$NC" + docker volume prune -f >/dev/null 2>&1 || true fi + + printf "%b[clean]%b done\n" "$GREEN" "$NC" } # ───────────────────────────────────────────────────────────────────────────── -# 6h. HELP MARKDOWN +# 6g. HELP MARKDOWN # ───────────────────────────────────────────────────────────────────────────── normalize_service() { @@ -1978,7 +1875,11 @@ cmd_rebuild() { # accepts: "all" or "1,3,5-7" or mix with names "nginx,2,5-6" # ----------------------------- _pick_targets_interactive() { - mapfile -t all_svcs < <(docker_compose config --services 2>/dev/null) + compose_services_load + all_svcs=("${__COMPOSE_SVCS[@]}") + if ((${#all_svcs[@]})); then + mapfile -t all_svcs < <(printf '%s\n' "${all_svcs[@]}" | LC_ALL=C sort -f -u) + fi [[ ${#all_svcs[@]} -gt 0 ]] || die "No services found (docker compose config --services failed?)" echo @@ -2051,7 +1952,8 @@ cmd_rebuild() { if (($# == 0)); then _pick_targets_interactive elif [[ "${1,,}" == "all" ]]; then - mapfile -t targets < <(docker_compose config --services 2>/dev/null) + compose_services_load + targets=("${__COMPOSE_SVCS[@]}") [[ ${#targets[@]} -gt 0 ]] || die "No services found (docker compose config --services failed?)" else for arg in "$@"; do @@ -2107,26 +2009,27 @@ docker_shell() { cmd_tools() { local sub="${1:-sh}" shift || true + local ctr + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" case "${sub,,}" in - sh|shell|"") - docker_shell SERVER_TOOLS + sh | shell | "") + docker_shell "$ctr" ;; exec) - [[ $# -gt 0 ]] || die "tools exec """ - docker exec -it SERVER_TOOLS sh -lc "$*" + [[ $# -gt 0 ]] || die "tools exec " + docker exec -it "$ctr" sh -lc "$*" ;; file) local p="${1:-}" [[ -n "$p" ]] || die "tools file " - docker exec -it SERVER_TOOLS sh -lc "ls -la -- \"$p\" 2>/dev/null || true; echo; sed -n '1,200p' -- \"$p\" 2>/dev/null || true" + docker exec -it "$ctr" sh -lc "ls -la -- \"$p\" 2>/dev/null || true; echo; sed -n '1,200p' -- \"$p\" 2>/dev/null || true" ;; *) die "tools " ;; esac } -cmd_lzd() { docker exec -it SERVER_TOOLS lazydocker; } -cmd_lazydocker() { cmd_lzd; } cmd_http() { [[ ${1:-} == reload ]] && http_reload; } cmd_cli() { local ctr="${1:-}" @@ -2159,103 +2062,92 @@ cmd_cli() { cmd_core() { # Usage: - # lds core -> open correct container for that domain (PHP/Node) - # lds core -> list domains and let user pick - - local domain="${1:-}" - if [[ -z "$domain" ]]; then - # No domain supplied: show an indexed list so selection is explicit and reliable. - domain="$(core_pick_domain)" || { - local rc=$? - ((rc == 130)) && return 130 - die "No domain selected" - } - fi + # lds core -> open correct container for that domain (PHP/Node) + # lds core -> open a shell in that container + # lds core -> list domains and let user pick - local nconf="$DIR/configuration/nginx/$domain.conf" - [[ -f "$nconf" ]] || die "No Nginx config for $domain" + local target="${1:-}" - # Node vhost? - if grep -Eq 'proxy_pass[[:space:]]+http://node_[A-Za-z0-9._-]+:[0-9]+' "$nconf"; then - launch_node "$domain" - return 0 - fi + # domain regex (same as domain-which/mkhost family) + local re='^([a-zA-Z0-9]([-a-zA-Z0-9]{0,61}[a-zA-Z0-9])?\.)+(localhost|local|test|loc|[a-zA-Z]{2,})$' - # Otherwise keep legacy PHP behavior - launch_php "$domain" - return 0 -} + # If no target -> prompt from domain-which list + if [[ -z "$target" ]]; then + local tools_ctr + tools_ctr="$(_project_tools_container_running || true)" + [[ -n "$tools_ctr" ]] || die "server-tools container is not running for project: $(lds_project)" -core_pick_domain() { - local -a domains=() - local f d + local -a domains=() + mapfile -t domains < <(docker exec "$tools_ctr" domain-which --list-domains 2>/dev/null | sed '/^[[:space:]]*$/d' || true) - shopt -s nullglob - for f in "$DIR/configuration/nginx/"*.conf; do - d="$(basename -- "$f" .conf)" - [[ -n "$d" ]] && domains+=("$d") - done - shopt -u nullglob + ((${#domains[@]} > 0)) || die "No domains found" - ((${#domains[@]} > 0)) || die "No domains found in $DIR/configuration/nginx" + # stable ordering + IFS=$'\n' domains=($(printf '%s\n' "${domains[@]}" | LC_ALL=C sort -u)) - # stable ordering - IFS=$'\n' domains=($(printf '%s\n' "${domains[@]}" | LC_ALL=C sort -u)) + if ((${#domains[@]} == 1)); then + target="${domains[0]}" + else + if [[ ! -t 0 ]]; then + printf "%b[core]%b No domain provided. Available domains:\n" "$YELLOW" "$NC" >&2 + local i=1 + local d + for d in "${domains[@]}"; do + printf " %2d) %s\n" "$i" "$d" >&2 + ((i++)) + done + die "No TTY to prompt. Use: lds core " + fi - # If there's only one domain, just use it. - if ((${#domains[@]} == 1)); then - printf '%s' "${domains[0]}" - return 0 - fi + printf "%bSelect domain:%b\n" "$CYAN" "$NC" >&2 + local i=1 d + for d in "${domains[@]}"; do + printf " %b%2d)%b %s\n" "$CYAN" "$i" "$NC" "$d" >&2 + ((i++)) + done - # Must be interactive to pick. - if [[ ! -t 0 ]]; then - printf "%b[core]%b No domain provided. Available domains:\n" "$YELLOW" "$NC" >&2 - local i=1 - for d in "${domains[@]}"; do - printf " %2d) %s\n" "$i" "$d" >&2 - ((i++)) - done - die "No TTY to prompt. Use: lds core " + local ans="" + while true; do + read -r -p "Enter number (1-${#domains[@]}): " ans + ans="$(echo "$ans" | xargs)" + [[ "$ans" =~ ^[0-9]+$ ]] || { + printf "%bInvalid input.%b\n" "$YELLOW" "$NC" >&2 + continue + } + ((ans >= 1 && ans <= ${#domains[@]})) || { + printf "%bOut of range.%b\n" "$YELLOW" "$NC" >&2 + continue + } + target="${domains[$((ans - 1))]}" + break + done + fi fi - printf "%bSelect domain:%b\n" "$CYAN" "$NC" >&2 - local i=1 - for d in "${domains[@]}"; do - printf " %2d) %s\n" "$i" "$d" >&2 - ((i++)) - done - printf " %2d) %s\n" 0 "Cancel" >&2 + # If target looks like a domain -> resolve via domain-which then shell in + if [[ "$target" =~ $re ]]; then + local tools_ctr + tools_ctr="$(_project_tools_container_running || true)" + [[ -n "$tools_ctr" ]] || die "server-tools container is not running for project: $(lds_project)" - local ans idx - while true; do - printf "%bDomain #%b " "$GREEN" "$NC" >&2 - tty_readline ans "" || return 130 - ans="${ans//[[:space:]]/}" - [[ -n "$ans" ]] || continue + local app container wd + app="$(docker exec "$tools_ctr" domain-which --app --quiet "$target" 2>/dev/null)" || die "Unknown domain: $target" + container="$(docker exec "$tools_ctr" domain-which --container --quiet "$target" 2>/dev/null)" || die "No container resolved for: $target" + wd="$(docker exec "$tools_ctr" domain-which --docroot --quiet "$target" 2>/dev/null)" || true + [[ -n "${container:-}" ]] || die "No container resolved for: $target" - if [[ "$ans" == "0" ]]; then - return 130 + # Node apps should always land at /app. Others follow resolved docroot. + if [[ "${app:-}" == "node" ]]; then + wd="/app" fi + [[ -n "${wd:-}" ]] || wd="/app" - if [[ "$ans" =~ ^[0-9]+$ ]]; then - idx=$((ans - 1)) - if ((idx >= 0 && idx < ${#domains[@]})); then - printf '%s' "${domains[$idx]}" - return 0 - fi - else - # allow typing domain directly - for d in "${domains[@]}"; do - if [[ "$d" == "$ans" ]]; then - printf '%s' "$d" - return 0 - fi - done - fi + docker exec -it "$container" bash -lc "cd \"$wd\" 2>/dev/null || cd /app 2>/dev/null || cd /; exec bash" + return 0 + fi - printf "%bInvalid selection.%b\n" "$YELLOW" "$NC" >&2 - done + # Otherwise treat target as a container name + docker exec -it "$(printf '%s' "$target" | tr '[:lower:]' '[:upper:]')" sh -lc 'exec bash -i || exec sh' } cmd_setup() { @@ -2285,307 +2177,15 @@ cmd_certificate() { esac } -cmd_doctor() { - # Optional focused modes - if [[ "${1:-}" == "--lint" ]]; then - shift || true - # Run shellcheck inside SERVER_TOOLS against lds + bin scripts (if present) - docker exec -i SERVER_TOOLS sh -lc ' - set -e - command -v shellcheck >/dev/null 2>&1 || { echo "shellcheck not found in SERVER_TOOLS"; exit 1; } - files="" - for f in "'"$DIR"'"/lds "'"$DIR"'"/bin/* "'"$DIR"'"/lib/*.sh; do - [ -f "$f" ] && files="$files $f" - done - [ -n "$files" ] || { echo "No scripts found to lint"; exit 0; } - shellcheck -x $files - ' - return 0 - fi - - if [[ "${1:-}" == "--scan-logs" ]]; then - shift || true - local pat="${1:-error|failed|panic|segfault|permission denied|fatal}" - local project; project="$(lds_project)" - # tail recent logs from compose services and grep with rg - docker_compose logs --since 30m 2>&1 | rg -n -i -- "$pat" || true - return 0 - fi - - local os_id os_like - IFS='|' read -r os_id os_like < <(detect_os_family) - - local is_win=0 - [[ "${OSTYPE:-}" =~ (msys|cygwin|win32) ]] && is_win=1 - - printf "%bDoctor%b — environment checks\n" "$MAGENTA" "$NC" - printf "%bOS%b: id=%s like=%s\n" "$CYAN" "$NC" "${os_id:-unknown}" "${os_like:-unknown}" - ((is_win)) && printf "%bShell%b: Windows\n" "$CYAN" "$NC" || printf "%bShell%b: Unix\n" "$CYAN" "$NC" - - _ok() { printf " %b✔%b %s\n" "$GREEN" "$NC" "$*"; } - _warn() { printf " %b!%b %s\n" "$YELLOW" "$NC" "$*"; } - _bad() { printf " %b✘%b %s\n" "$RED" "$NC" "$*"; } - _has() { command -v "$1" >/dev/null 2>&1; } - _first() { - local c - for c in "$@"; do _has "$c" && { - echo "$c" - return 0 - }; done - return 1 - } - - # ──────────────────────────────────────────────────────────────────────────── - # TTY sanity (prompt/menus) - # ──────────────────────────────────────────────────────────────────────────── - [[ -t 0 ]] && _ok "stdin: ok" || _warn "stdin: not a TTY" - [[ -t 1 ]] && _ok "stdout: ok" || _warn "stdout: not a TTY" - [[ -r /dev/tty ]] && _ok "tty: ok" || _warn "tty: not readable" - - # ──────────────────────────────────────────────────────────────────────────── - # Required base tools - # ──────────────────────────────────────────────────────────────────────────── - local c - for c in awk sed grep find sort; do - _has "$c" && _ok "$c: ok" || _bad "$c: missing" - done - - # ──────────────────────────────────────────────────────────────────────────── - # Windows essentials - # ──────────────────────────────────────────────────────────────────────────── - if ((is_win)); then - _has powershell.exe && _ok "powershell.exe: ok" || _bad "powershell.exe: missing" - _has cygpath && _ok "cygpath: ok" || _bad "cygpath: missing" - _has git.exe && _ok "git.exe: ok" || _warn "git.exe: missing" - _has bash && _ok "bash: ok" || _bad "bash: missing" - fi - - # ──────────────────────────────────────────────────────────────────────────── - # Docker CLI + daemon + context - # ──────────────────────────────────────────────────────────────────────────── - local docker_cmd - docker_cmd="$(_first docker docker.exe || true)" - - if [[ -n "$docker_cmd" ]]; then - _ok "docker: $($docker_cmd --version 2>/dev/null || echo ok)" - - if $docker_cmd context show >/dev/null 2>&1; then - _ok "docker context: $($docker_cmd context show 2>/dev/null)" - else - _warn "docker context: unavailable" - fi - - if $docker_cmd info >/dev/null 2>&1; then - _ok "docker daemon: ok" - else - _bad "docker daemon: not reachable" - ((is_win)) && _warn "hint: start Docker Desktop; enable WSL2 engine" - fi - else - _bad "docker: missing" - fi - - # Compose presence (doctor should NOT print compose file path) - if docker_compose version >/dev/null 2>&1; then - _ok "compose: ok" - else - _bad "compose: missing" - fi - - # Compose config validation (reuse your wrapper; keep quiet) - if docker_compose config -q >/dev/null 2>&1; then - _ok "compose config: ok" - else - _bad "compose config: invalid (run: lds config)" - fi - - # ──────────────────────────────────────────────────────────────────────────── - # Optional tools (high-signal only) - # ──────────────────────────────────────────────────────────────────────────── - _has openssl && _ok "openssl: ok" || _warn "openssl: missing" - (_has wget || _has curl) && _ok "http client: ok" || _warn "http client: missing (curl/wget)" - _has jq && _ok "jq: ok" || _warn "jq: missing" - - # ──────────────────────────────────────────────────────────────────────────── - # File/dir sanity (only show path when NOT ok) - # ──────────────────────────────────────────────────────────────────────────── - [[ -f "$ENV_DOCKER" ]] && _ok "env: ok" || _warn "env: missing ($ENV_DOCKER)" - [[ -d "$DIR/docker" ]] && _ok "docker dir: ok" || _bad "docker dir: missing ($DIR/docker)" - [[ -d "$DIR/configuration" ]] && _ok "configuration dir: ok" || _bad "configuration dir: missing ($DIR/configuration)" - [[ -d "$DIR/data" ]] && _ok "data dir: ok" || _warn "data dir: missing ($DIR/data)" - [[ -d "$DIR/logs" ]] && _ok "logs dir: ok" || _warn "logs dir: missing ($DIR/logs)" - - # Writable checks (mount stability) - if [[ -d "$DIR/data" ]]; then - [[ -w "$DIR/data" ]] && _ok "data writable: ok" || _bad "data writable: no ($DIR/data)" - fi - if [[ -d "$DIR/logs" ]]; then - [[ -w "$DIR/logs" ]] && _ok "logs writable: ok" || _bad "logs writable: no ($DIR/logs)" - fi - - # Cert input (needed by your cert commands) - [[ -f "$DIR/configuration/rootCA/rootCA.pem" ]] && - _ok "rootCA.pem: ok" || - _warn "rootCA.pem: missing ($DIR/configuration/rootCA/rootCA.pem)" - - # CRLF risk (bash scripts break) - if [[ -f "$DIR/lds" ]]; then - if grep -U $'\r' "$DIR/lds" >/dev/null 2>&1; then - _warn "line endings: CRLF detected ($DIR/lds)" - else - _ok "line endings: ok" - fi - fi - - # ──────────────────────────────────────────────────────────────────────────── - # More checks (requested) - # ──────────────────────────────────────────────────────────────────────────── - printf "\n%bMore checks%b\n" "$CYAN" "$NC" - - # Ports availability — only public ports: 80/443 - doctor_port_in_use() { - local p="$1" - if ((is_win)); then - netstat -ano 2>/dev/null | awk '{print $2}' | grep -Eq ":${p}\$" && return 0 || return 1 - else - if _has ss; then - ss -lnt 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${p}\$" && return 0 || return 1 - elif _has lsof; then - lsof -nP -iTCP:"$p" -sTCP:LISTEN >/dev/null 2>&1 && return 0 || return 1 - else - return 2 - fi - fi - } - - local ports=(80 443) - local any_unknown=0 p - for p in "${ports[@]}"; do - if doctor_port_in_use "$p"; then - _warn "port $p: in use" - else - local rc=$? - if [[ $rc -eq 2 ]]; then - any_unknown=1 - else - _ok "port $p: ok" - fi - fi - done - ((any_unknown)) && _warn "port scan: limited (install ss or lsof)" - - # Disk space (warn only if low) - if df -Pk "$DIR" >/dev/null 2>&1; then - local avail_kb - avail_kb="$(df -Pk "$DIR" | awk 'NR==2{print $4}')" - if [[ -n "$avail_kb" ]] && ((avail_kb < 5 * 1024 * 1024)); then - _warn "disk: low (<5GiB free)" - else - _ok "disk: ok" - fi - else - _warn "disk: unable to check" - fi - - # WSL detection (Windows) - if ((is_win)); then - if _has wsl.exe; then - if wsl.exe -l -q >/dev/null 2>&1; then - _ok "WSL: ok" - else - _warn "WSL: present but not configured" - fi - else - _warn "WSL: not found" - fi - fi - - # Compose profiles sanity (.env vs supported profiles) - doctor_list_enabled_profiles() { - [[ -f "$ENV_DOCKER" ]] || return 0 - local line - line="$(grep -E '^(COMPOSE_PROFILES|PROFILES)=' "$ENV_DOCKER" 2>/dev/null | tail -n 1 || true)" - [[ -n "$line" ]] || return 0 - line="${line#*=}" - line="${line%\"}" - line="${line#\"}" - line="${line%\'}" - line="${line#\'}" - echo "$line" | tr ', ' '\n' | awk 'NF{print}' - } - - local -A valid_profiles=() - local key slug - for key in "${SERVICE_ORDER[@]}"; do - slug="${SERVICES[$key]}" - [[ -n "$slug" ]] && valid_profiles["$slug"]=1 - done - - local seen_any=0 bad_any=0 prof - while IFS= read -r prof; do - [[ -n "$prof" ]] || continue - seen_any=1 - if [[ -n "${valid_profiles[$prof]:-}" ]]; then - _ok "profiles: '$prof' ok" - else - bad_any=1 - _warn "profiles: unknown '$prof'" - fi - done < <(doctor_list_enabled_profiles || true) - - ((seen_any == 0)) && _warn "profiles: none set (COMPOSE_PROFILES=...)" - ((bad_any)) && _warn "profiles: sync COMPOSE_PROFILES with supported services" - - # ──────────────────────────────────────────────────────────────────────────── - # Tips - # ──────────────────────────────────────────────────────────────────────────── - printf "\n%bTips%b\n" "$CYAN" "$NC" - printf " - Use: %blds certificate uninstall --all%b if you switched distros/paths.\n" "$BLUE" "$NC" - printf " - Use: %blds config%b to inspect full resolved compose config.\n" "$BLUE" "$NC" - - # ──────────────────────────────────────────────────────────────────────────── - # Install hints (bottom) - # ──────────────────────────────────────────────────────────────────────────── - printf "\n%bInstall hints%b\n" "$CYAN" "$NC" - - if ((is_win)); then - printf " - Install Docker Desktop (WSL2 engine recommended)\n" - printf " - Install Git for Windows (Git Bash)\n" - printf " - Ensure docker.exe is on PATH and Docker Desktop is running\n" - printf " - (Optional) Install WSL: wsl --install\n" - elif _has apt-get; then - printf " - Debian/Ubuntu:\n" - printf " sudo apt-get update && sudo apt-get install -y ca-certificates curl wget openssl p11-kit jq\n" - printf " sudo apt-get install -y docker-compose-plugin\n" - elif _has dnf; then - printf " - Fedora/RHEL (dnf):\n" - printf " sudo dnf install -y ca-certificates curl wget openssl p11-kit-trust jq\n" - elif _has yum; then - printf " - RHEL/CentOS (yum):\n" - printf " sudo yum install -y ca-certificates curl wget openssl p11-kit-trust jq\n" - elif _has pacman; then - printf " - Arch:\n" - printf " sudo pacman -Syu --noconfirm ca-certificates curl wget openssl p11-kit jq\n" - elif _has apk; then - printf " - Alpine:\n" - printf " sudo apk add --no-cache ca-certificates curl wget openssl p11-kit jq\n" - printf " sudo update-ca-certificates\n" - elif _has zypper; then - printf " - SUSE:\n" - printf " sudo zypper install -y ca-certificates curl wget openssl p11-kit jq\n" - else - printf " - Install: docker + compose + ca-certificates + openssl + p11-kit (+ jq optional)\n" - fi - - # Extra feature: show quick “where to go next” suggestion only if env missing - [[ -f "$ENV_DOCKER" ]] || _warn "next: run 'lds setup init' to generate env defaults" -} - ############################################################################### # NOTIFY ############################################################################### notify_watch() { - local container="${1:-SERVER_TOOLS}" + local container="${1:-}" + if [[ -z "$container" ]]; then + container="$(_project_tools_container_running || true)" + [[ -n "$container" ]] || die "server-tools container is not running for project: $(lds_project)" + fi local prefix="__HOST_NOTIFY__" need docker @@ -2598,7 +2198,7 @@ notify_watch() { local timeout="${1:-2500}" urgency="${2:-normal}" title="${3:-Notification}" body="${4:-}" # Linux desktop (or WSLg) - if command -v notify-send >/dev/null 2>&1; then + if has_cmd notify-send; then (env DISPLAY="${_disp-}" DBUS_SESSION_BUS_ADDRESS="${_dbus-}" \ setsid -f notify-send -u "$urgency" -t "$timeout" "$title" "$body" \ >/dev/null 2>&1 || true) & @@ -2606,7 +2206,7 @@ notify_watch() { fi # Windows toast (Git Bash) / WSL-on-Windows - if command -v powershell.exe >/dev/null 2>&1; then + if has_cmd powershell.exe; then # Pass values as args to avoid quoting issues entirely. # Note: urgency/timeout not used by toast api here; kept for parity. powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \ @@ -2657,7 +2257,7 @@ notify_watch() { trap _watcher_int_term INT TERM local grep_cmd=(grep -a --line-buffered -E "^${prefix}([[:space:]]|$)") - command -v stdbuf >/dev/null 2>&1 && grep_cmd=(stdbuf -oL -eL "${grep_cmd[@]}") + has_cmd stdbuf && grep_cmd=(stdbuf -oL -eL "${grep_cmd[@]}") printf "%bNotify Watch:%b monitoring is active. Ctrl+C to stop.\n" "$GREEN" "$NC" @@ -2715,13 +2315,16 @@ notify_watch() { notify_test() { local title="${1:-Notifier OK}" - local body="${2:-Hello from host via SERVER_TOOLS}" - docker exec SERVER_TOOLS notify -t 2500 -u normal "$title" "$body" + local body="${2:-Hello from host via project server-tools container}" + local ctr + ctr="$(_project_tools_container_running || true)" + [[ -n "$ctr" ]] || die "server-tools container is not running for project: $(lds_project)" + docker exec "$ctr" notify -t 2500 -u normal "$title" "$body" } cmd_notify() { case ${1:-watch} in - watch) notify_watch "${2:-SERVER_TOOLS}" ;; + watch) notify_watch "${2:-}" ;; test) notify_test "${2:-Notifier OK}" "${3:-Hello from host}" ;; *) die "notify " ;; esac @@ -2733,25 +2336,25 @@ open_url() { # WSL/Windows helpers first when available if grep -qi microsoft /proc/version 2>/dev/null; then - if command -v powershell.exe >/dev/null 2>&1; then + if has_cmd powershell.exe; then powershell.exe -NoProfile -Command "Start-Process '$url'" >/dev/null 2>&1 || true return 0 fi - if command -v cmd.exe >/dev/null 2>&1; then + if has_cmd cmd.exe; then cmd.exe /c start "" "$url" >/dev/null 2>&1 || true return 0 fi fi - if command -v xdg-open >/dev/null 2>&1; then + if has_cmd xdg-open; then (xdg-open "$url" >/dev/null 2>&1 &) return 0 fi - if command -v open >/dev/null 2>&1; then + if has_cmd open; then (open "$url" >/dev/null 2>&1 &) return 0 fi - if command -v powershell >/dev/null 2>&1; then + if has_cmd powershell; then (powershell -NoProfile -Command "Start-Process '$url'" >/dev/null 2>&1 &) return 0 fi @@ -2764,9 +2367,9 @@ open_url() { ############################################################################### hash_short() { local s="$1" - if command -v sha1sum >/dev/null 2>&1; then + if has_cmd sha1sum; then printf '%s' "$s" | sha1sum | cut -c1-8 - elif command -v shasum >/dev/null 2>&1; then + elif has_cmd shasum; then printf '%s' "$s" | shasum -a 1 | cut -c1-8 else # POSIX fallback; stable (not cryptographic) @@ -2799,7 +2402,16 @@ run_find_container() { run_build() { local tag="$1" dir="$2" - printf "%b[run]%b Building image %b%s%b from %s\n" "$CYAN" "$NC" "$BLUE" "$tag" "$NC" "$dir" + + # Build only if the image doesn't already exist. + if docker image inspect "$tag" >/dev/null 2>&1; then + printf "%b[run]%b Image exists, skipping build: %b%s%b +" "$CYAN" "$NC" "$BLUE" "$tag" "$NC" + return 0 + fi + + printf "%b[run]%b Building image %b%s%b from %s +" "$CYAN" "$NC" "$BLUE" "$tag" "$NC" "$dir" docker build -t "$tag" "$dir" } @@ -2868,7 +2480,7 @@ run_start() { fi # If user provided Windows path (E:\...), convert to POSIX for existence check - if [[ "$host" =~ ^[A-Za-z]:[\\/].* ]] && command -v cygpath >/dev/null 2>&1; then + if [[ "$host" =~ ^[A-Za-z]:[\\/].* ]] && has_cmd cygpath; then host="$(cygpath -u "$host")" fi @@ -2908,7 +2520,8 @@ run_start() { done if [[ "$keepalive" == 1 ]]; then - args+=(--entrypoint sh "$tag" -c "trap : TERM INT; sleep infinity & wait") + # Keepalive mode replaces the image command; disable image healthcheck to avoid false "unhealthy". + args+=(--no-healthcheck --entrypoint sh "$tag" -c "trap : TERM INT; sleep infinity & wait") else args+=("$tag") fi @@ -2920,6 +2533,7 @@ run_start() { printf "%b[run]%b docker run failed.\n" "$RED" "$NC" >&2 return 1 fi + printf "\n" } run_exec_shell() { @@ -2932,16 +2546,20 @@ run_exec_shell() { } cmd_run() { - local action="shell" dir="$PWD" name="" tag="" nobuild=0 keepalive=1 sock=0 + local action="*" dir="$PWD" name="" tag="" nobuild=0 keepalive=1 sock=0 local -a publish=() mounts=() local open_port="" open_path="/" open_proto="http" while [[ $# -gt 0 ]]; do case "$1" in - stop | rm | ps | shell | logs | open) + stop | rm | ps | shell | logs | open | "*") action="$1" shift ;; + build) + action="*" + shift + ;; --name) name="${2:-}" shift 2 @@ -3003,7 +2621,7 @@ cmd_run() { if is_windows_shell; then export MSYS_NO_PATHCONV=1 export MSYS2_ARG_CONV_EXCL='*' - if command -v cygpath >/dev/null 2>&1; then + if has_cmd cygpath; then dir_docker="$(cygpath -w "$dir_posix")" fi fi @@ -3011,7 +2629,17 @@ cmd_run() { # Plan/name/tag should be based on the real project identity (POSIX dir) IFS='|' read -r def_name def_tag _def_dir < <(run_plan "$dir_posix") name="${name:-$def_name}" - tag="${tag:-$def_tag}" + + # Tag rules: + # - Default tag is ":local" (from run_plan) + # - If user passes --tag without ":", append ":local" + if [[ -n "${tag:-}" ]]; then + if [[ "$tag" != *:* ]]; then + tag="${tag}:local" + fi + else + tag="$def_tag" + fi _find_for_dir() { local found @@ -3027,6 +2655,55 @@ cmd_run() { return 1 } + _run_build_summary() { + local img="$1" build_dir="$2" cname="$3" + local tag_only="${img##*:}" + + printf "\n%b[run]%b Build summary\n" "$CYAN" "$NC" + printf " %bImage:%b %s\n" "$BOLD" "$NC" "$img" + printf " %bTag:%b %s\n" "$BOLD" "$NC" "$tag_only" + printf " %bDir:%b %s\n" "$BOLD" "$NC" "$build_dir" + printf " %bName:%b %s\n" "$BOLD" "$NC" "$cname" + printf " %bKeepalive:%b %s\n" "$BOLD" "$NC" "$keepalive" + printf " %bSock:%b %s\n" "$BOLD" "$NC" "$sock" + + if ((${#publish[@]})); then + printf " %bPublish:%b %s\n" "$BOLD" "$NC" "${publish[*]}" + else + printf " %bPublish:%b (none)\n" "$BOLD" "$NC" + fi + + if ((${#mounts[@]})); then + printf " %bMounts:%b %s\n" "$BOLD" "$NC" "${mounts[*]}" + else + printf " %bMounts:%b (none)\n" "$BOLD" "$NC" + fi + printf "\n" + } + + _run_runtime_summary() { + local cname="$1" + local id img state ports + id="$(docker inspect -f '{{.Id}}' "$cname" 2>/dev/null | cut -c1-12 || true)" + img="$(docker inspect -f '{{.Config.Image}}' "$cname" 2>/dev/null || true)" + state="$(docker inspect -f '{{.State.Status}}' "$cname" 2>/dev/null || true)" + ports="$(docker port "$cname" 2>/dev/null | sed '/^[[:space:]]*$/d' | tr '\n' '; ' | sed 's/; $//' || true)" + + printf "%b[run]%b Runtime summary\n" "$CYAN" "$NC" + printf " %bContainer:%b %s\n" "$BOLD" "$NC" "${cname}${id:+ ($id)}" + [[ -n "$img" ]] && printf " %bImage:%b %s\n" "$BOLD" "$NC" "$img" + [[ -n "$state" ]] && printf " %bState:%b %s\n" "$BOLD" "$NC" "$state" + if [[ -n "$ports" ]]; then + printf " %bPorts:%b %s\n" "$BOLD" "$NC" "$ports" + else + printf " %bPorts:%b (none published)\n" "$BOLD" "$NC" + fi + printf "%b\n[run]%b Example Usage (in Composer)\n" "$CYAN" "$NC" + printf " %bimage:%b %s\n" "$BOLD" "$NC" "$img" + printf " %bpull_policy:%b never\n" "$BOLD" "$NC" + printf "\n" + } + case "$action" in ps) docker ps -a --filter "label=com.infocyph.lds.run=1" \ @@ -3090,7 +2767,7 @@ cmd_run() { fi return 0 ;; - shell | *) + shell | "*") if ((nobuild == 0)); then # Build needs docker.exe-friendly path on Windows run_build "$tag" "$dir_docker" @@ -3098,8 +2775,10 @@ cmd_run() { printf "%b[run]%b Skipping build (--no-build)\n" "$YELLOW" "$NC" fi + _run_build_summary "$tag" "$dir_posix" "$name" + if docker inspect -f '{{.State.Running}}' "$name" 2>/dev/null | grep -q true; then - printf "%b[run]%b Container already running: %s\n" "$GREEN" "$NC" "$name" + printf "%b[run]%b Container already running: %s\n\n" "$GREEN" "$NC" "$name" else if docker inspect "$name" >/dev/null 2>&1; then docker rm -f "$name" >/dev/null 2>&1 || true @@ -3111,69 +2790,322 @@ cmd_run() { "${publish[@]}" -- "${mounts[@]}" fi - run_exec_shell "$name" + _run_runtime_summary "$name" + + # "shell" enters the container; "*" / "build" does not. + if [[ "$action" == "shell" ]]; then + run_exec_shell "$name" + else + printf "%b[run]%b Built/started. Use %blds run shell%b to enter, %blds run logs%b to follow logs.\n" \ + "$GREEN" "$NC" "$BLUE" "$NC" "$BLUE" "$NC" + return 0 + fi ;; esac } +############################################################################### +# 6w. NEW FEATURES: stack diff | support trace +############################################################################### + +# stack diff: show what would run (compose) vs what's running (docker) +cmd_stack_diff() { + local json=0 + local show_config=0 + while [[ "${1:-}" ]]; do + case "$1" in + --json) + json=1 + shift + ;; + --config) + show_config=1 + shift + ;; + *) break ;; + esac + done + + local project + project="$(lds_project)" + local cfg_json="" + + if docker_compose config --format json >/dev/null 2>&1; then + cfg_json="$(docker_compose config --format json)" + else + # fallback: best-effort text config + cfg_json="" + fi + + # running: service -> image + declare -A running=() + local line + while IFS= read -r line; do + [[ -n "$line" ]] || continue + local svc="${line%%|*}" + local img="${line#*|}" + running["$svc"]="$img" + done < <(docker ps \ + --filter "label=com.docker.compose.project=$project" \ + --format '{{index .Labels "com.docker.compose.service"}}|{{.Image}}' 2>/dev/null || true) + + # desired: service -> image/build context (best-effort) + declare -A desired_img=() + declare -A desired_ctx=() + declare -A desired_df=() + + if [[ -n "$cfg_json" ]]; then + if has_tool jq; then + while IFS= read -r line; do + local svc="${line%%|*}" + local img="${line#*|}" + desired_img["$svc"]="$img" + done < <(printf '%s' "$cfg_json" | jq -r '.services | to_entries[] | "\(.key)|\(.value.image // "")"') + while IFS= read -r line; do + local svc="${line%%|*}" + local ctx="${line#*|}" + desired_ctx["$svc"]="$ctx" + done < <(printf '%s' "$cfg_json" | jq -r '.services | to_entries[] | "\(.key)|\(.value.build.context // "")"') + while IFS= read -r line; do + local svc="${line%%|*}" + local df="${line#*|}" + desired_df["$svc"]="$df" + done < <(printf '%s' "$cfg_json" | jq -r '.services | to_entries[] | "\(.key)|\(.value.build.dockerfile // "")"') + elif _server_tools_has jq; then + # Fallback: parse via project tools container jq through stdin (no shell re-quoting of JSON payload). + local ctr + ctr="$(_project_tools_container_running || true)" + if [[ -n "$ctr" ]]; then + while IFS= read -r line; do + local svc="${line%%|*}" + local img="${line#*|}" + desired_img["$svc"]="$img" + done < <(printf '%s' "$cfg_json" | docker exec -i "$ctr" jq -r '.services | to_entries[] | "\(.key)|\(.value.image // "")"' 2>/dev/null || true) + while IFS= read -r line; do + local svc="${line%%|*}" + local ctx="${line#*|}" + desired_ctx["$svc"]="$ctx" + done < <(printf '%s' "$cfg_json" | docker exec -i "$ctr" jq -r '.services | to_entries[] | "\(.key)|\(.value.build.context // "")"' 2>/dev/null || true) + while IFS= read -r line; do + local svc="${line%%|*}" + local df="${line#*|}" + desired_df["$svc"]="$df" + done < <(printf '%s' "$cfg_json" | docker exec -i "$ctr" jq -r '.services | to_entries[] | "\(.key)|\(.value.build.dockerfile // "")"' 2>/dev/null || true) + fi + fi + fi + + # Build result object + if ((json)); then + if has_tool jq; then + # assemble in bash -> jq + local tmp + tmp="$(mktemp)" + { + printf '{' + printf '"project":%s,' "$(printf '%s' "$project" | jq -Rsa .)" + printf '"compose_file":%s,' "$(printf '%s' "$COMPOSE_FILE" | jq -Rsa .)" + printf '"running":{' + local first=1 k + for k in "${!running[@]}"; do + ((first)) || printf ',' + first=0 + printf '%s:%s' "$(printf '%s' "$k" | jq -R .)" "$(printf '%s' "${running[$k]}" | jq -R .)" + done + printf '},' + printf '"desired":{' + first=1 + for k in "${!desired_img[@]}"; do + ((first)) || printf ',' + first=0 + printf '%s:%s' "$(printf '%s' "$k" | jq -R .)" "$(printf '%s' "${desired_img[$k]}" | jq -R .)" + done + printf '},' + printf '"diff":[' + first=1 + # union keys + declare -A seen=() + for k in "${!running[@]}"; do seen["$k"]=1; done + for k in "${!desired_img[@]}"; do seen["$k"]=1; done + for k in "${!seen[@]}"; do + local r="${running[$k]:-}" + local d="${desired_img[$k]:-}" + if [[ "$r" != "$d" ]]; then + ((first)) || printf ',' + first=0 + printf '{"service":%s,"running":%s,"desired":%s}' \ + "$(printf '%s' "$k" | jq -R .)" \ + "$(printf '%s' "$r" | jq -R .)" \ + "$(printf '%s' "$d" | jq -R .)" + fi + done + printf ']' + printf '}\n' + } >"$tmp" + cat "$tmp" | jq . + rm -f "$tmp" + else + die "jq required for --json (or run inside project server-tools container)" + fi + return 0 + fi + + printf "%bStack diff%b (project=%s)\n" "$CYAN" "$NC" "$project" + printf "%bCompose file:%b %s\n" "$DIM" "$NC" "$COMPOSE_FILE" + + if ((show_config)); then + if [[ -n "$cfg_json" ]]; then + printf "\n%bEffective compose config (json):%b\n" "$DIM" "$NC" + printf '%s\n' "$cfg_json" + else + printf "\n%bEffective compose config:%b\n" "$DIM" "$NC" + docker_compose config || true + fi + fi + + # union services + declare -A all=() + local svc + for svc in "${!running[@]}"; do all["$svc"]=1; done + for svc in "${!desired_img[@]}"; do all["$svc"]=1; done + + printf "\n%-22s %-40s %-40s %s\n" "SERVICE" "RUNNING" "DESIRED" "STATUS" + printf "%-22s %-40s %-40s %s\n" "------" "-------" "-------" "------" + for svc in $(printf '%s\n' "${!all[@]}" | sort); do + local r="${running[$svc]:-}" + local d="${desired_img[$svc]:-}" + local st + if [[ -z "$r" ]]; then + st="(not running)" + elif [[ -z "$d" ]]; then + st="(not in config)" + elif [[ "$r" == "$d" ]]; then + st="OK" + else + st="DIFF" + fi + printf "%-22s %-40.40s %-40.40s %s\n" "$svc" "$r" "$d" "$st" + done + + printf "\n%bNotes:%b\n" "$DIM" "$NC" + printf " - Desired image is derived from 'docker compose config'. If a service uses only 'build:' and no 'image:', desired may be empty.\n" + printf " - Use: lds stack diff --config (to print resolved compose config)\n" +} + +# support trace: quick end-to-end trace for a domain +cmd_support_trace() { + local dom="${1:-}" + [[ -n "$dom" ]] || die "support trace " + + local nconf="$DIR/configuration/nginx/$dom.conf" + printf "%bTrace%b: %s\n" "$CYAN" "$NC" "$dom" + + # 1) DNS + if _server_tools_running; then + printf "\n%b[DNS]%b\n" "$DIM" "$NC" + _tools_exec "dig +short $(_shq "$dom") || true; getent hosts $(_shq "$dom") 2>/dev/null || true" + else + printf "\n%b[DNS]%b\n" "$DIM" "$NC" + (has_cmd dig && dig +short "$dom") || true + (has_cmd getent && getent hosts "$dom") || true + fi + + # 2) TLS certificate + printf "\n%b[TLS]%b\n" "$DIM" "$NC" + if _server_tools_running; then + _tools_exec "echo | openssl s_client -connect $(_shq "$dom"):443 -servername $(_shq "$dom") -showcerts 2>/dev/null | openssl x509 -noout -subject -issuer -dates 2>/dev/null || true" + else + echo | openssl s_client -connect "${dom}:443" -servername "$dom" -showcerts 2>/dev/null | openssl x509 -noout -subject -issuer -dates 2>/dev/null || true + fi + + # 3) HTTP probe (timings) + printf "\n%b[HTTP]%b\n" "$DIM" "$NC" + if _server_tools_running; then + _tools_exec "curl -sk -o /dev/null -D - -w 'time_namelookup=%{time_namelookup}\ntime_connect=%{time_connect}\ntime_appconnect=%{time_appconnect}\ntime_starttransfer=%{time_starttransfer}\ntime_total=%{time_total}\nhttp_code=%{http_code}\n' https://$(_shq "$dom") | sed -n '1,30p'" + else + curl -sk -o /dev/null -D - -w $'time_namelookup=%{time_namelookup}\ntime_connect=%{time_connect}\ntime_appconnect=%{time_appconnect}\ntime_starttransfer=%{time_starttransfer}\ntime_total=%{time_total}\nhttp_code=%{http_code}\n' "https://$dom" | sed -n '1,30p' + fi + + # 4) Upstream inference from nginx conf (if exists) + printf "\n%b[Upstream]%b\n" "$DIM" "$NC" + if [[ -r "$nconf" ]]; then + if grep -q fastcgi_pass "$nconf"; then + local php + php="$(grep -Eo 'fastcgi_pass[[:space:]]+[^;]+' "$nconf" | awk '{print $2}' | head -n1 || true)" + printf "type=php\nfastcgi_pass=%s\n" "${php:-unknown}" + elif grep -q proxy_pass "$nconf"; then + local up + up="$(grep -m1 -Eo 'proxy_pass[[:space:]]+http[s]?://[^;]+' "$nconf" | awk '{print $2}' | head -n1 || true)" + printf "type=proxy\nproxy_pass=%s\n" "${up:-unknown}" + else + printf "type=static\n" + fi + else + printf "nginx_conf=%s (missing)\n" "$nconf" + fi + + # 5) Recent nginx logs (compose) + printf "\n%b[Recent nginx logs]%b\n" "$DIM" "$NC" + docker_compose logs --no-color --tail 120 nginx 2>/dev/null | text_grep -i "$dom" || docker_compose logs --no-color --tail 120 nginx 2>/dev/null || true + + printf "\n%bDone.%b If this still looks wrong, run: lds diag tls %s\n" "$GREEN" "$NC" "$dom" +} + ############################################################################### # 6x. GROUPED COMMAND ROUTERS (stack/domain/support) + backward-compatible aliases ############################################################################### cmd_stack() { - local sub="${1:-}"; shift || true + local sub="${1:-}" + shift || true case "${sub,,}" in - ""|help|-h|--help) cmd_help stack ;; - up) cmd_up "$@" ;; - start) cmd_start "$@" ;; - down|stop) cmd_down "$@" ;; - restart|reboot) cmd_restart "$@" ;; - reload) cmd_reload "$@" ;; - status) cmd_status "$@" ;; - ps) cmd_ps "$@" ;; - logs) cmd_logs "$@" ;; - exec) cmd_exec "$@" ;; - stats) cmd_stats "$@" ;; - events) cmd_events "$@" ;; - clean) cmd_clean "$@" ;; - verify) cmd_verify "$@" ;; - disk) cmd_disk "$@" ;; - du) cmd_du "$@" ;; - config) cmd_config "$@" ;; - http) cmd_http "$@" ;; - *) - die "stack " - ;; + "" | help | -h | --help) cmd_help stack ;; + up) cmd_up "$@" ;; + start) cmd_start "$@" ;; + down | stop) cmd_down "$@" ;; + restart | reboot) cmd_restart "$@" ;; + status) cmd_status "$@" ;; + ps) cmd_ps "$@" ;; + logs) cmd_logs "$@" ;; + exec) cmd_exec "$@" ;; + events) cmd_events "$@" ;; + clean) cmd_clean "$@" ;; + config) cmd_config "$@" ;; diff) cmd_stack_diff "$@" ;; + + http) cmd_http "$@" ;; + *) + die "stack " + ;; esac } # Canonical: domain. Legacy: host. cmd_domain() { - local sub="${1:-}"; shift || true + local sub="${1:-}" + shift || true case "${sub,,}" in - ""|help|-h|--help) die "domain " ;; - add) cmd_host add "$@" ;; - rm|remove|del|delete) cmd_host rm "$@" ;; - ls|list) cmd_host list "$@" ;; - check) cmd_check upstream "$@" ;; - nginx) cmd_nginx "$@" ;; - *) - die "domain " - ;; + "" | help | -h | --help) die "domain " ;; + add) cmd_host add "$@" ;; + rm | remove | del | delete) cmd_host rm "$@" ;; + ls | list) cmd_host list "$@" ;; + *) + die "domain " + ;; esac } cmd_support() { - local sub="${1:-}"; shift || true + local sub="${1:-}" + shift || true case "${sub,,}" in - ""|help|-h|--help) die "support " ;; - open) cmd_open "$@" ;; - bundle) cmd_bundle "$@" ;; - notify) cmd_notify "$@" ;; - ui) cmd_ui "$@" ;; - *) - die "support " - ;; + "" | help | -h | --help) die "support " ;; + open) cmd_open "$@" ;; + bundle) cmd_bundle "$@" ;; + notify) cmd_notify "$@" ;; + ui) cmd_ui "$@" ;; + *) + die "support " + ;; esac } @@ -3186,17 +3118,29 @@ cmd_bundle() { local out="${1:-}" while [[ "${1:-}" ]]; do case "$1" in - --redact) mode="redact"; shift ;; - --full) mode="full"; shift ;; - *.zip) out="$1"; shift ;; - *) break ;; + --redact) + mode="redact" + shift + ;; + --full) + mode="full" + shift + ;; + *.zip) + out="$1" + shift + ;; + *) break ;; esac done need zip - local ts; ts="$(date +%Y%m%d_%H%M%S)" - local project; project="$(lds_project)" - local tmp; tmp="$(mktemp -d "${TMPDIR:-/tmp}/lds_bundle.XXXXXX")" + local ts + ts="$(date +%Y%m%d_%H%M%S)" + local project + project="$(lds_project)" + local tmp + tmp="$(mktemp -d "${TMPDIR:-/tmp}/lds_bundle.XXXXXX")" local base="lds_bundle_${project}_${ts}" [[ -n "$out" ]] || out="$PWD/${base}.zip" @@ -3272,28 +3216,24 @@ cmd_help() { cat <<'MD' # LocalDevStack (lds) — Command Reference -## Stack (compose + runtime) +## Stack (compose) - `lds stack up` *(aliases: `up`)* -- `lds stack up` *(aliases: `start`)* +- `lds stack start` *(aliases: `start`)* - `lds stack down [--volumes --yes]` *(aliases: `down`, `stop`)* - `lds stack restart [svc]` *(aliases: `restart`, `reboot`)* -- `lds stack reload` -- `lds stack status [--json] [--quiet]` *(alias: `status`)* +- `lds stack status [status-args…]` *(alias: `status`; forwards args to tools `status`)* - `lds stack ps` *(alias: `ps`)* - `lds stack logs [svc] [--follow] [--since ] [--grep ]` *(alias: `logs`)* - `lds stack exec [cmd…]` *(alias: `exec`)* -- `lds stack stats [svc]` *(alias: `stats`)* - `lds stack events [--since ]` *(alias: `events`)* - `lds stack clean --yes [--volumes]` *(alias: `clean`)* -- `lds stack verify` *(alias: `verify`)* -- `lds stack disk` / `lds stack du` *(aliases: `disk`, `du`)* +- `lds stack diff [--config] [--json]` *(shows desired vs running images)* + ## Domain (vhost lifecycle + routing) - `lds domain add …` - `lds domain rm …` - `lds domain ls` -- `lds domain check ` -- `lds domain nginx ` Legacy alias: `lds host …` → same subcommands as `domain`. @@ -3318,41 +3258,30 @@ Legacy alias: `lds host …` → same subcommands as `domain`. - `lds config env-used` - `lds config validate` -## Doctor -- `lds doctor` / `lds doctor run` -- `lds doctor lint` -- `lds doctor scan-logs` -- `lds doctor fix` - ## Support -- `lds support open ` +- `lds support open + support trace ` - `lds support bundle [--redact|--full]` - `lds support notify …` - `lds support ui` Shortcuts: `open`, `bundle`, `notify`, `ui` map to `support …`. -## Secrets / Env (senv) +## Secrets (senv) - `lds secrets …` -- `lds env …` -## Tools (SERVER_TOOLS) +## Tools (project server-tools container) - `lds tools sh` - `lds tools exec ""` - `lds tools file ` -- `lds lzd` / `lds lazydocker` ## Setup - `lds setup init|permissions|domain|profiles` ## Runner (ad‑hoc Dockerfile runner) -- `lds run` (+ `ps|logs|stop|rm|open` and flags: `--publish|-p`, `--no-keepalive`, `--mount`, `--sock`) - -## Runtime awareness -- `lds runtime php|node` +- `lds run` *(default: build+start only)* / `lds run shell` *(build+start+enter)* / `lds run *` *(same as default)* (+ `ps|logs|stop|rm|open` and flags: `--publish|-p`, `--no-keepalive`, `--mount`, `--sock`, `--tag`, `--name`) ## Other -- `lds nginx ` - `lds rebuild [all|]` - `lds core [domain]` @@ -3369,18 +3298,18 @@ MD cat < - clean|verify|disk|du Alias of: stack <...> + status|ps|logs|exec|events Alias of: stack <...> + clean Alias of: stack <...> ${CYAN}Domain (vhosts + routing):${NC} - domain add|rm|ls|check|nginx - host add|rm|list|check|nginx Legacy alias of: domain <...> + domain add|rm|ls + host add|rm|list Legacy alias of: domain <...> ${CYAN}Certificates (TLS):${NC} cert status|regen|diagnose @@ -3393,26 +3322,19 @@ ${CYAN}Diagnostics:${NC} ${CYAN}Config:${NC} config show|services|profiles|env-used|validate -${CYAN}Doctor:${NC} - doctor [run] Full environment checks - doctor lint shellcheck (inside SERVER_TOOLS) - doctor scan-logs [pattern] rg scan (recent logs) - doctor fix Safe auto-fixes (when available) - ${CYAN}Support:${NC} support open + support trace support bundle [--redact|--full] support notify ... support ui open|bundle|notify|ui Shortcuts → support <...> -${CYAN}Secrets / Env:${NC} +${CYAN}Secrets:${NC} secrets - env -${CYAN}Tools (SERVER_TOOLS):${NC} +${CYAN}Tools (project server-tools container):${NC} tools sh|exec|file - lzd|lazydocker ${CYAN}Setup:${NC} setup init|permissions|domain|profiles @@ -3421,8 +3343,6 @@ ${CYAN}Runner (ad-hoc Dockerfile runner):${NC} run [ps|logs|stop|rm|open] [--publish|-p A:B] [--no-keepalive] [--mount HOST[:CONT]] [--sock] ${CYAN}Other:${NC} - runtime php|node - nginx rebuild [all|] core [domain] @@ -3432,14 +3352,10 @@ ${CYAN}Help:${NC} EOF } - ############################################################################### # 7. MAIN ############################################################################### main() { - need docker - ((EUID == 0)) || ensure_files_exist "/docker/.env" "/configuration/php/php.ini" "/.env" - [[ $# -gt 0 ]] || { cmd_help exit 1 @@ -3449,16 +3365,26 @@ main() { case "$1" in -v | --verbose) VERBOSE=1 + QUIET=0 shift ;; -q | --quiet) + QUIET=1 VERBOSE=0 shift ;; + --reload-extras) + export LDS_EXTRAS_RELOAD=1 + shift + ;; --) shift break ;; + -h | --help) + cmd_help + exit 0 + ;; -*) die "Unknown global option: $1" ;; *) break ;; esac @@ -3469,13 +3395,32 @@ main() { exit 1 } - case "${1,,}" in - php | composer | node | npm | npx) exec "$DIR/bin/$1" "${@:2}" ;; - pg | pg_restore | pg-restore | pgrestore | pg_dump | pgdump | pg-dump | psql) exec "$DIR/bin/pg" "${@:2}" ;; - maria | mariadb | mariadbdump | mariadb-dump | mariadb_dump) exec "$DIR/bin/maria" "${@:2}" ;; - my | mysql | mysqldump | mysql-dump | mysql_dump) exec "$DIR/bin/my" "${@:2}" ;; - redis | redis-cli) exec "$DIR/bin/redis-cli" "${@:2}" ;; - *) cmd_"${1,,}" "${@:2}" ;; + local cmd="${1,,}" + shift || true + + if [[ "$cmd" == "help" ]]; then + cmd_help "$@" + exit 0 + fi + + # Do not require docker for pure help output; everything else expects the stack. + need docker + + case "$cmd" in + php | composer | node | npm | npx) exec "$DIR/bin/$cmd" "$@" ;; + pg | psql | pg_restore | pg-restore | pgrestore | pg_dump | pgdump | pg-dump) exec "$DIR/bin/pg" "$@" ;; + maria | mariadb | mariadbdump | mariadb-dump | mariadb_dump) exec "$DIR/bin/maria" "$@" ;; + my | mysql | mysqldump | mysql-dump | mysql_dump) exec "$DIR/bin/my" "$@" ;; + redis | redis-cli) exec "$DIR/bin/redis-cli" "$@" ;; + es | elastic | elasticsearch) exec "$DIR/bin/es" "$@" ;; + mongo | mongodb | mongosh | mongoimport | mongoexport) exec "$DIR/bin/mongo" "$@" ;; + *) + if declare -F "cmd_$cmd" >/dev/null 2>&1; then + "cmd_$cmd" "$@" + else + exec "$DIR/bin/tool-runner" "$cmd" "$@" + fi + ;; esac } diff --git a/logs/.gitignore b/logs/.gitignore index 8ba10b5..c8eca25 100755 --- a/logs/.gitignore +++ b/logs/.gitignore @@ -12,5 +12,7 @@ !mongodb !postgresql !redis +!php-fpm !olds +!node !.gitignore \ No newline at end of file diff --git a/logs/cloudbeaver/.gitignore b/logs/cloudbeaver/.gitignore old mode 100644 new mode 100755 diff --git a/logs/elasticsearch/.gitignore b/logs/elasticsearch/.gitignore old mode 100644 new mode 100755 diff --git a/logs/kibana/.gitignore b/logs/kibana/.gitignore old mode 100644 new mode 100755 diff --git a/logs/mongo-express/.gitignore b/logs/mongo-express/.gitignore old mode 100644 new mode 100755 diff --git a/logs/mongodb/.gitignore b/logs/mongodb/.gitignore old mode 100644 new mode 100755 diff --git a/configuration/nginx/.gitignore b/logs/node/.gitignore similarity index 100% rename from configuration/nginx/.gitignore rename to logs/node/.gitignore diff --git a/configuration/node/.gitignore b/logs/php-fpm/.gitignore old mode 100644 new mode 100755 similarity index 100% rename from configuration/node/.gitignore rename to logs/php-fpm/.gitignore diff --git a/logs/postgresql/.gitignore b/logs/postgresql/.gitignore old mode 100644 new mode 100755 diff --git a/logs/redis-insight/.gitignore b/logs/redis-insight/.gitignore old mode 100644 new mode 100755 diff --git a/logs/redis/.gitignore b/logs/redis/.gitignore old mode 100644 new mode 100755