initial
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
# Preflight checks. Fail loud and early with actionable messages.
|
||||
|
||||
check_bash() {
|
||||
if ((BASH_VERSINFO[0] < 4)); then
|
||||
ui_err "$(t err_bash_version "$BASH_VERSION")"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_docker() {
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
ui_err "$(t err_need_docker)"; exit 1
|
||||
fi
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
ui_err "$(t err_docker_daemon)"; exit 1
|
||||
fi
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
ui_err "$(t err_need_compose)"; exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
check_openssl() {
|
||||
if ! command -v openssl >/dev/null 2>&1; then
|
||||
ui_err "$(t err_need_openssl)"; exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# is_port_free PORT — best effort; returns 0 if nothing seems to listen on it.
|
||||
is_port_free() {
|
||||
local port="$1"
|
||||
if command -v lsof >/dev/null 2>&1; then
|
||||
! lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1
|
||||
elif command -v nc >/dev/null 2>&1; then
|
||||
! nc -z localhost "$port" >/dev/null 2>&1
|
||||
else
|
||||
return 0 # can't check — assume free
|
||||
fi
|
||||
}
|
||||
|
||||
preflight() {
|
||||
check_bash
|
||||
check_docker
|
||||
check_openssl
|
||||
ui_ok "$(t preflight_ok)"
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
# docker-compose.yml + Caddyfile generation by gluing service fragments.
|
||||
#
|
||||
# Fragments live in templates/compose/. A few carry @TOKENS@ that depend on the
|
||||
# user's choices (published ports, depends_on for embedded backing services);
|
||||
# those are substituted here. External backing services contribute no fragment.
|
||||
|
||||
_frag() { cat "${BOOTSTRAP_DIR}/templates/compose/$1"; }
|
||||
|
||||
generate_compose() {
|
||||
local nl=$'\n'
|
||||
local api_ports="" api_depends="" worker_depends="" webui_ports="" caddy_ports=""
|
||||
local dep=""
|
||||
|
||||
# depends_on: only wait on backing services we actually run, and only when
|
||||
# they are healthy (never start the backend over an unready dependency).
|
||||
[[ "$CFG_DB_MODE" == "embedded" ]] && dep+=" db:${nl} condition: service_healthy${nl}"
|
||||
[[ "$CFG_REDIS_MODE" == "embedded" ]] && dep+=" redis:${nl} condition: service_healthy${nl}"
|
||||
if [[ -n "$dep" ]]; then
|
||||
api_depends=" depends_on:${nl}${dep%"$nl"}"
|
||||
worker_depends="$api_depends"
|
||||
fi
|
||||
|
||||
# API port: published whenever there is no bundled proxy (backend-only, or
|
||||
# webui with the operator's own proxy). Behind Caddy the API stays in-network.
|
||||
if [[ "$CFG_PROXY" == "no" ]]; then
|
||||
api_ports=" ports:${nl} - \"\${API_PORT}:8000\""
|
||||
fi
|
||||
|
||||
# Webui port: published only when the webui runs without the bundled proxy.
|
||||
if [[ "$CFG_WEBUI" == "yes" && "$CFG_PROXY" == "no" ]]; then
|
||||
webui_ports=" ports:${nl} - \"\${WEBUI_PORT}:80\""
|
||||
fi
|
||||
|
||||
# Caddy publish ports: 80/443 with a domain (auto-HTTPS), else plain HTTP.
|
||||
if [[ "$CFG_HTTPS" == "yes" ]]; then
|
||||
caddy_ports=" - \"80:80\"${nl} - \"443:443\""
|
||||
else
|
||||
caddy_ports=" - \"\${HTTP_PORT}:80\""
|
||||
fi
|
||||
|
||||
# -- assemble ---------------------------------------------------------
|
||||
local backend webui caddy
|
||||
backend="$(_frag backend.yml)"
|
||||
backend="${backend//@API_PORTS@/$api_ports}"
|
||||
backend="${backend//@API_DEPENDS@/$api_depends}"
|
||||
backend="${backend//@WORKER_DEPENDS@/$worker_depends}"
|
||||
|
||||
{
|
||||
_frag _base.yml
|
||||
printf '%s\n' "$backend"
|
||||
|
||||
if [[ "$CFG_WEBUI" == "yes" ]]; then
|
||||
webui="$(_frag webui.yml)"
|
||||
webui="${webui//@WEBUI_PORTS@/$webui_ports}"
|
||||
printf '%s\n' "$webui"
|
||||
fi
|
||||
|
||||
[[ "$CFG_DB_MODE" == "embedded" ]] && _frag postgres.yml
|
||||
[[ "$CFG_REDIS_MODE" == "embedded" ]] && _frag redis.yml
|
||||
[[ "$CFG_STORAGE" == "s3" && "$CFG_S3_MODE" == "embedded" ]] && _frag minio.yml
|
||||
|
||||
if [[ "$CFG_PROXY" == "yes" ]]; then
|
||||
caddy="$(_frag caddy.yml)"
|
||||
caddy="${caddy//@CADDY_PORTS@/$caddy_ports}"
|
||||
printf '%s\n' "$caddy"
|
||||
fi
|
||||
|
||||
# -- named volumes (only those actually referenced) ---------------
|
||||
echo ""
|
||||
echo "volumes:"
|
||||
echo " transcode_cache:"
|
||||
[[ "$CFG_DB_MODE" == "embedded" ]] && echo " pgdata:"
|
||||
[[ "$CFG_REDIS_MODE" == "embedded" ]] && echo " redisdata:"
|
||||
[[ "$CFG_STORAGE" == "s3" && "$CFG_S3_MODE" == "embedded" ]] && echo " miniodata:"
|
||||
if [[ "$CFG_PROXY" == "yes" ]]; then
|
||||
echo " caddy_data:"
|
||||
echo " caddy_config:"
|
||||
fi
|
||||
} >"$COMPOSE_FILE"
|
||||
|
||||
[[ "$CFG_PROXY" == "yes" ]] && generate_caddyfile
|
||||
}
|
||||
|
||||
# generate_caddyfile — concrete Caddyfile (no env indirection). Routes API
|
||||
# paths to the backend and everything else to the static webui.
|
||||
generate_caddyfile() {
|
||||
local site
|
||||
if [[ "$CFG_HTTPS" == "yes" ]]; then site="$CFG_DOMAIN"; else site=":80"; fi
|
||||
|
||||
{
|
||||
if [[ "$CFG_HTTPS" == "yes" && -n "${CFG_ACME_EMAIL:-}" ]]; then
|
||||
echo "{"
|
||||
echo " email ${CFG_ACME_EMAIL}"
|
||||
echo "}"
|
||||
echo ""
|
||||
fi
|
||||
echo "${site} {"
|
||||
echo " @api path /api/* /health* /rest/*"
|
||||
echo " reverse_proxy @api api:8000"
|
||||
echo " reverse_proxy webui:80"
|
||||
echo "}"
|
||||
} >"$CADDYFILE"
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# .env.deploy generation. Renders templates/env/env.template (token
|
||||
# substitution) and appends conditional blocks for the chosen options.
|
||||
# The file is written with mode 600 and never echoed to stdout.
|
||||
|
||||
# render_template FILE — substitutes @KEY@ tokens from the RENDER assoc array.
|
||||
# Prints the result to stdout.
|
||||
render_template() {
|
||||
local file="$1" content key
|
||||
content="$(cat "$file")"
|
||||
for key in "${!RENDER[@]}"; do
|
||||
content="${content//@${key}@/${RENDER[$key]}}"
|
||||
done
|
||||
printf '%s\n' "$content"
|
||||
}
|
||||
|
||||
# build_database_url — sets CFG_DATABASE_URL from the collected DB config.
|
||||
build_database_url() {
|
||||
if [[ "$CFG_DB_MODE" == "embedded" ]]; then
|
||||
CFG_DATABASE_URL="postgresql+asyncpg://${CFG_DB_USER}:${CFG_DB_PASS}@db:5432/${CFG_DB_NAME}"
|
||||
else
|
||||
CFG_DATABASE_URL="postgresql+asyncpg://${CFG_DB_USER}:${CFG_DB_PASS}@${CFG_DB_HOST}:${CFG_DB_PORT}/${CFG_DB_NAME}"
|
||||
fi
|
||||
}
|
||||
|
||||
# generate_env — writes $ENV_FILE from template + conditional sections.
|
||||
generate_env() {
|
||||
build_database_url
|
||||
|
||||
declare -A RENDER=(
|
||||
[MCMA_IMAGE_TAG]="$CFG_TAG"
|
||||
[DATABASE_URL]="$CFG_DATABASE_URL"
|
||||
[REDIS_URL]="$CFG_REDIS_URL"
|
||||
[JWT_SECRET]="$CFG_JWT_SECRET"
|
||||
[MEDIA_HOST_PATH]="$CFG_MEDIA_HOST_PATH"
|
||||
[STORAGE_BACKEND]="$CFG_STORAGE"
|
||||
[PUBLIC_API_BASE_URL]="$CFG_PUBLIC_API_BASE_URL"
|
||||
[HTTP_PORT]="$CFG_HTTP_PORT"
|
||||
[API_PORT]="$CFG_API_PORT"
|
||||
[WEBUI_PORT]="$CFG_WEBUI_PORT"
|
||||
)
|
||||
|
||||
umask 077
|
||||
render_template "${BOOTSTRAP_DIR}/templates/env/env.template" >"$ENV_FILE"
|
||||
|
||||
# -- embedded Postgres credentials (needed by the db service) ---------
|
||||
if [[ "$CFG_DB_MODE" == "embedded" ]]; then
|
||||
{
|
||||
echo ""
|
||||
echo "# -- built-in Postgres --------------------------------------------------"
|
||||
echo "POSTGRES_USER=${CFG_DB_USER}"
|
||||
echo "POSTGRES_PASSWORD=${CFG_DB_PASS}"
|
||||
echo "POSTGRES_DB=${CFG_DB_NAME}"
|
||||
} >>"$ENV_FILE"
|
||||
fi
|
||||
|
||||
# -- S3 storage -------------------------------------------------------
|
||||
if [[ "$CFG_STORAGE" == "s3" ]]; then
|
||||
{
|
||||
echo ""
|
||||
echo "# -- S3 storage ---------------------------------------------------------"
|
||||
echo "S3_ENDPOINT_URL=${CFG_S3_ENDPOINT}"
|
||||
echo "S3_BUCKET=${CFG_S3_BUCKET}"
|
||||
echo "S3_REGION=${CFG_S3_REGION}"
|
||||
echo "S3_ACCESS_KEY=${CFG_S3_KEY}"
|
||||
echo "S3_SECRET_KEY=${CFG_S3_SECRET}"
|
||||
} >>"$ENV_FILE"
|
||||
fi
|
||||
|
||||
# -- optional ML service ----------------------------------------------
|
||||
if [[ -n "${CFG_ML_URL:-}" ]]; then
|
||||
{
|
||||
echo ""
|
||||
echo "# -- optional ML service ------------------------------------------------"
|
||||
echo "ML_SERVICE_URL=${CFG_ML_URL}"
|
||||
} >>"$ENV_FILE"
|
||||
fi
|
||||
|
||||
chmod 600 "$ENV_FILE"
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# i18n loader. Exposes the MSG associative array and the `t` accessor.
|
||||
#
|
||||
# i18n_load en # populate MSG from i18n/en.sh
|
||||
# t step_db # echo MSG[step_db]
|
||||
# t pull_images foo # printf MSG[pull_images] with args (for %s placeholders)
|
||||
#
|
||||
# Strings live in i18n/<locale>.sh; logic never hardcodes user-facing text.
|
||||
|
||||
declare -A MSG
|
||||
|
||||
i18n_load() {
|
||||
local locale="$1"
|
||||
local file="${BOOTSTRAP_DIR}/i18n/${locale}.sh"
|
||||
[[ -f "$file" ]] || file="${BOOTSTRAP_DIR}/i18n/en.sh"
|
||||
# shellcheck source=/dev/null
|
||||
source "$file"
|
||||
LOCALE="$locale"
|
||||
}
|
||||
|
||||
# t KEY [printf-args...] — resolve a string, optionally formatting placeholders.
|
||||
t() {
|
||||
local key="$1"; shift
|
||||
local s="${MSG[$key]:-$key}"
|
||||
if (($# > 0)); then
|
||||
# shellcheck disable=SC2059
|
||||
printf "$s" "$@"
|
||||
else
|
||||
printf '%s' "$s"
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
# Lifecycle: pull, ordered start (deps → migrate → app), first admin, health,
|
||||
# plus update/down/logs/status/clean. Wraps `docker compose` with the project's
|
||||
# env file and generated compose file.
|
||||
|
||||
PROJECT="${MCMA_PROJECT:-mcma}"
|
||||
|
||||
dc() {
|
||||
docker compose \
|
||||
--project-name "$PROJECT" \
|
||||
--project-directory "$BOOTSTRAP_DIR" \
|
||||
--env-file "$ENV_FILE" \
|
||||
-f "$COMPOSE_FILE" "$@"
|
||||
}
|
||||
|
||||
# backing_services — the embedded dependencies present in the compose file that
|
||||
# must be healthy/complete before migrations (subset of db redis minio*).
|
||||
backing_services() {
|
||||
local all want s out=""
|
||||
all="$(dc config --services 2>/dev/null)"
|
||||
for want in db redis minio minio-setup; do
|
||||
while IFS= read -r s; do
|
||||
[[ "$s" == "$want" ]] && out+="$want "
|
||||
done <<<"$all"
|
||||
done
|
||||
printf '%s' "${out% }"
|
||||
}
|
||||
|
||||
# app_services — everything that is not a one-shot/backing dependency wait.
|
||||
ensure_media_dir() {
|
||||
# MEDIA_HOST_PATH is read from the generated env file.
|
||||
local p
|
||||
p="$(grep -E '^MEDIA_HOST_PATH=' "$ENV_FILE" | cut -d= -f2-)"
|
||||
[[ -n "$p" ]] || return 0
|
||||
# Resolve relative paths against the project directory (compose does too).
|
||||
[[ "$p" = /* ]] || p="${BOOTSTRAP_DIR}/${p#./}"
|
||||
mkdir -p "$p"
|
||||
}
|
||||
|
||||
lifecycle_pull() {
|
||||
ui_info "$(t pull_images "$(grep -E '^MCMA_IMAGE_TAG=' "$ENV_FILE" | cut -d= -f2-)")"
|
||||
ui_dim "$(t pull_hint)"
|
||||
dc pull
|
||||
}
|
||||
|
||||
# wait_api_healthy — poll the running API's liveness endpoint over the compose
|
||||
# network (no host port / TLS assumptions). Returns non-zero on timeout.
|
||||
wait_api_healthy() {
|
||||
ui_info "$(t checking_health)"
|
||||
dc run --rm --no-deps -T api python -c '
|
||||
import urllib.request, time, sys
|
||||
for _ in range(60):
|
||||
try:
|
||||
if urllib.request.urlopen("http://api:8000/health", timeout=2).status == 200:
|
||||
sys.exit(0)
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(2)
|
||||
sys.exit(1)
|
||||
'
|
||||
}
|
||||
|
||||
# lifecycle_start CREATE_ADMIN(yes|no)
|
||||
# Ordered, fail-loud startup. Never starts the backend over a broken DB.
|
||||
lifecycle_start() {
|
||||
local create_admin="${1:-no}"
|
||||
ensure_media_dir
|
||||
lifecycle_pull
|
||||
|
||||
local deps; deps="$(backing_services)"
|
||||
if [[ -n "$deps" ]]; then
|
||||
ui_info "$(t starting_deps)"
|
||||
# shellcheck disable=SC2086
|
||||
dc up -d --wait $deps
|
||||
fi
|
||||
|
||||
ui_info "$(t running_migrations)"
|
||||
if ! dc run --rm --no-deps -T api alembic upgrade head; then
|
||||
ui_err "$(t err_migrations_failed)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$create_admin" == "yes" ]]; then
|
||||
ui_info "$(t creating_admin)"
|
||||
# Password passed via --password to avoid interactive prompts; not echoed.
|
||||
dc run --rm --no-deps -T api mcma create-admin "$CFG_ADMIN_USER" --password "$CFG_ADMIN_PASS"
|
||||
fi
|
||||
|
||||
ui_info "$(t starting_app)"
|
||||
dc up -d
|
||||
|
||||
if ! wait_api_healthy; then
|
||||
ui_err "$(t err_health_timeout)"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# -- management commands ---------------------------------------------------
|
||||
lifecycle_update() {
|
||||
lifecycle_pull
|
||||
local deps; deps="$(backing_services)"
|
||||
if [[ -n "$deps" ]]; then
|
||||
# shellcheck disable=SC2086
|
||||
dc up -d --wait $deps
|
||||
fi
|
||||
ui_info "$(t running_migrations)"
|
||||
if ! dc run --rm --no-deps -T api alembic upgrade head; then
|
||||
ui_err "$(t err_migrations_failed)"; exit 1
|
||||
fi
|
||||
dc up -d
|
||||
wait_api_healthy || { ui_err "$(t err_health_timeout)"; exit 1; }
|
||||
ui_ok "$(t done_title)"
|
||||
}
|
||||
|
||||
lifecycle_up() { ensure_media_dir; dc up -d; }
|
||||
lifecycle_down() { dc down; }
|
||||
lifecycle_logs() { dc logs -f --tail=100; }
|
||||
|
||||
lifecycle_status() {
|
||||
dc ps
|
||||
echo
|
||||
if wait_api_healthy >/dev/null 2>&1; then ui_ok "API: ok"; else ui_warn "API: not ready"; fi
|
||||
}
|
||||
|
||||
lifecycle_clean() {
|
||||
dc down
|
||||
if ui_yesno "$(t clean_volumes_confirm)" "no"; then
|
||||
dc down -v
|
||||
fi
|
||||
if ui_yesno "$(t clean_confirm)" "yes"; then
|
||||
rm -f "$COMPOSE_FILE" "$CADDYFILE" "$ENV_FILE"
|
||||
ui_ok "$(t clean_done)"
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
# Secret generation. Secrets are generated, never requested from the user,
|
||||
# and never printed. URL-safe alphanumerics only (avoid shell/URL/env quoting
|
||||
# pitfalls in .env and connection strings).
|
||||
#
|
||||
# Note: we deliberately avoid `... | head -c N` here. Under `set -o pipefail`
|
||||
# head closes the pipe early, the upstream producer dies with SIGPIPE, and the
|
||||
# whole script would abort. Instead we read a full chunk and slice in bash.
|
||||
|
||||
# gen_secret [LENGTH] — alphanumeric token, default 32 chars. Sets SECRET.
|
||||
gen_secret() {
|
||||
local len="${1:-32}" raw=""
|
||||
while ((${#raw} < len)); do
|
||||
# openssl finishes before tr reads EOF — no early pipe close.
|
||||
raw+="$(openssl rand -base64 $((len + 16)) | LC_ALL=C tr -dc 'A-Za-z0-9')"
|
||||
done
|
||||
SECRET="${raw:0:len}"
|
||||
}
|
||||
|
||||
# gen_hex BYTES — hex string (e.g. JWT secret). Sets SECRET.
|
||||
gen_hex() {
|
||||
SECRET="$(openssl rand -hex "${1:-32}")"
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
# Pure-bash prompt helpers — no external TUI, no dependencies.
|
||||
#
|
||||
# Every prompt shows its default, re-asks on invalid input, and lets Ctrl+C
|
||||
# abort cleanly (the trap is installed in deploy.sh). Results are returned via
|
||||
# the REPLY-style globals documented on each function.
|
||||
|
||||
# -- colours (disabled if not a TTY or NO_COLOR set) -----------------------
|
||||
if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then
|
||||
C_RESET=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m'
|
||||
C_CYAN=$'\033[36m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_RED=$'\033[31m'
|
||||
else
|
||||
C_RESET=''; C_BOLD=''; C_DIM=''; C_CYAN=''; C_GREEN=''; C_YELLOW=''; C_RED=''
|
||||
fi
|
||||
|
||||
ui_title() { printf '\n%s%s%s\n' "$C_BOLD$C_CYAN" "$1" "$C_RESET"; }
|
||||
ui_info() { printf '%s\n' "$1"; }
|
||||
ui_dim() { printf '%s%s%s\n' "$C_DIM" "$1" "$C_RESET"; }
|
||||
ui_ok() { printf '%s✓ %s%s\n' "$C_GREEN" "$1" "$C_RESET"; }
|
||||
ui_warn() { printf '%s! %s%s\n' "$C_YELLOW" "$1" "$C_RESET"; }
|
||||
ui_err() { printf '%s✗ %s%s\n' "$C_RED" "$1" "$C_RESET" >&2; }
|
||||
|
||||
# ui_input PROMPT DEFAULT [VALIDATOR_FN]
|
||||
# Sets UI_VALUE. VALIDATOR_FN (optional) receives the candidate value and
|
||||
# returns 0 to accept; on rejection it should print why (to stderr).
|
||||
ui_input() {
|
||||
local prompt="$1" default="${2:-}" validator="${3:-}" ans
|
||||
while true; do
|
||||
if [[ -n "$default" ]]; then
|
||||
read -r -p "$prompt [${C_DIM}${default}${C_RESET}]: " ans || exit 130
|
||||
ans="${ans:-$default}"
|
||||
else
|
||||
read -r -p "$prompt: " ans || exit 130
|
||||
fi
|
||||
if [[ -n "$validator" ]]; then
|
||||
if ! "$validator" "$ans"; then continue; fi
|
||||
fi
|
||||
UI_VALUE="$ans"
|
||||
return 0
|
||||
done
|
||||
}
|
||||
|
||||
# ui_secret PROMPT — no echo. Sets UI_VALUE.
|
||||
ui_secret() {
|
||||
local prompt="$1" ans
|
||||
read -r -s -p "$prompt: " ans || exit 130
|
||||
echo
|
||||
UI_VALUE="$ans"
|
||||
}
|
||||
|
||||
# ui_yesno PROMPT DEFAULT(yes|no) — sets UI_BOOL to "yes"/"no", returns 0/1.
|
||||
ui_yesno() {
|
||||
local prompt="$1" default="${2:-yes}" ans hint
|
||||
[[ "$default" == "yes" ]] && hint="[Y/n]" || hint="[y/N]"
|
||||
while true; do
|
||||
read -r -p "$prompt $hint: " ans || exit 130
|
||||
ans="${ans:-$default}"
|
||||
case "${ans,,}" in
|
||||
y|yes|да|д) UI_BOOL="yes"; return 0 ;;
|
||||
n|no|нет|н) UI_BOOL="no"; return 1 ;;
|
||||
*) ui_warn "$(t invalid_input)" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# ui_select PROMPT "label1" "label2" ...
|
||||
# Numbered single-choice menu. Sets UI_INDEX (1-based) and UI_VALUE (label).
|
||||
ui_select() {
|
||||
local prompt="$1"; shift
|
||||
local -a opts=("$@")
|
||||
local i ans
|
||||
ui_info "$prompt"
|
||||
for i in "${!opts[@]}"; do
|
||||
printf ' %s%d%s) %s\n' "$C_CYAN" "$((i + 1))" "$C_RESET" "${opts[$i]}"
|
||||
done
|
||||
while true; do
|
||||
read -r -p "> [${C_DIM}1${C_RESET}]: " ans || exit 130
|
||||
ans="${ans:-1}"
|
||||
if [[ "$ans" =~ ^[0-9]+$ ]] && ((ans >= 1 && ans <= ${#opts[@]})); then
|
||||
UI_INDEX="$ans"
|
||||
UI_VALUE="${opts[$((ans - 1))]}"
|
||||
return 0
|
||||
fi
|
||||
ui_warn "$(t invalid_input)"
|
||||
done
|
||||
}
|
||||
|
||||
# ui_multiselect PROMPT "key1:label1:on" "key2:label2:off" "key3:label3:locked"
|
||||
# Toggle items by number; 'locked' items cannot be turned off. Empty line
|
||||
# confirms. Sets UI_SELECTED (space-separated keys that ended up on).
|
||||
ui_multiselect() {
|
||||
local prompt="$1"; shift
|
||||
local -a keys=() labels=() states=() locks=()
|
||||
local spec key label state
|
||||
for spec in "$@"; do
|
||||
key="${spec%%:*}"; spec="${spec#*:}"
|
||||
label="${spec%%:*}"; state="${spec#*:}"
|
||||
keys+=("$key"); labels+=("$label")
|
||||
if [[ "$state" == "locked" ]]; then states+=("on"); locks+=("yes")
|
||||
else states+=("$state"); locks+=("no"); fi
|
||||
done
|
||||
local i ans
|
||||
while true; do
|
||||
ui_info "$prompt"
|
||||
for i in "${!keys[@]}"; do
|
||||
local mark="[ ]"; [[ "${states[$i]}" == "on" ]] && mark="[x]"
|
||||
local lock=""; [[ "${locks[$i]}" == "yes" ]] && lock=" ${C_DIM}(required)${C_RESET}"
|
||||
printf ' %s%d%s) %s %s%s\n' "$C_CYAN" "$((i + 1))" "$C_RESET" "$mark" "${labels[$i]}" "$lock"
|
||||
done
|
||||
ui_dim "$(t enter_to_keep) — number toggles, Enter confirms"
|
||||
read -r -p "> " ans || exit 130
|
||||
[[ -z "$ans" ]] && break
|
||||
if [[ "$ans" =~ ^[0-9]+$ ]] && ((ans >= 1 && ans <= ${#keys[@]})); then
|
||||
local idx=$((ans - 1))
|
||||
if [[ "${locks[$idx]}" == "yes" ]]; then
|
||||
ui_warn "$(t backend_required)"
|
||||
elif [[ "${states[$idx]}" == "on" ]]; then states[$idx]="off"
|
||||
else states[$idx]="on"; fi
|
||||
else
|
||||
ui_warn "$(t invalid_input)"
|
||||
fi
|
||||
done
|
||||
UI_SELECTED=""
|
||||
for i in "${!keys[@]}"; do
|
||||
[[ "${states[$i]}" == "on" ]] && UI_SELECTED+="${keys[$i]} "
|
||||
done
|
||||
UI_SELECTED="${UI_SELECTED% }"
|
||||
}
|
||||
|
||||
# -- common validators (for ui_input) --------------------------------------
|
||||
v_nonempty() { [[ -n "$1" ]] || { ui_warn "$(t invalid_input)"; return 1; }; }
|
||||
v_port() { [[ "$1" =~ ^[0-9]+$ ]] && (($1 >= 1 && $1 <= 65535)) || { ui_warn "$(t invalid_input)"; return 1; }; }
|
||||
v_url() { [[ "$1" =~ ^https?:// ]] || { ui_warn "$(t invalid_input)"; return 1; }; }
|
||||
v_redis() { [[ "$1" =~ ^rediss?:// ]] || { ui_warn "$(t invalid_input)"; return 1; }; }
|
||||
v_domain() { [[ "$1" =~ ^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]] || { ui_warn "$(t invalid_input)"; return 1; }; }
|
||||
Reference in New Issue
Block a user