Files
Linux_post_install/lib/config-ui.sh
T
he 5ef38dc46f fix: resolve all 23 MAINTENANCE audit tickets
- deps guards before -h|--help in docker-health/ps, network-scan,
  usb-server, media-mp3/mp4 (--dry-run pre-scan kept); system-firewall
  gains usage()/--help; autostart/usb-automount get flags.sh + template
- install.sh: normalize N-M range syntax in --steps
- bin/pos: INTERACTIVE_CMDS += docker-compose docker-vbox network-hotspot
- common.sh: canonical XDG-aware CONFIG_DIR + DIM color var; notify.sh
  stderr fallback; ent_plugin_* registry renames (runtime plugin API kept)
- docker-compose SCALE_DIR/CONFIG_ENV env seams; ffmpeg in PACKAGES;
  scrcpy.sh exec bit
- docs: health is console-only (--send/--markdown removed), POS.md file
  refs for config/tree/entertainment, DEV.md no-guard exception, docmap/
  filetable regenerated (make gen), hand-maintained line rows bumped
- add scripts/lint-conventions.sh gate + Makefile lint target; record
  all VERIFIED outcomes in MAINTENANCE.md; AGENT_TODO Done entry
  (2026-08-14)
- gates: make gen/check/lint all green (0 FAIL, 0 WARN); bash -n sweep
  clean; restricted-PATH dep tests + step-matrix dry-runs verified
2026-08-14 12:57:00 -04:00

349 lines
12 KiB
Bash

# lib/config-ui.sh — interactive, self-describing config UI for the pos tools.
# Reads each tool's "# POS_CONFIG:" header — the config registry — and renders
# a numbered menu per scope to view/edit values in the matching env file.
# Self-contained: uses common.sh helpers when available, with guarded fallbacks
# (mirrors lib/notify.sh). Sourced opt-in by bin/pos-config.
#
# Header grammar — one "# POS_CONFIG:" line per scope a tool exposes:
# # POS_CONFIG: <scope> | <env-file> | <KEY>=<flags>:<desc>[::<example>] | ... | *plugins
# <env-file> basename of the config file under ~/.config/linux_post_install/
# <flags> secret (masked display + stty -echo input) | digits | num | float
# <example> optional value format hint shown in the editor, e.g. "weather,5m joke,10m"
# *plugins marker: also list every key declared by the installed
# entertainment plugins' "# POS_KEYS:" headers (dynamic)
# Example:
# # POS_CONFIG: telegram | telegram.env | TELEGRAM_BOT_TOKEN=secret:Bot token | TELEGRAM_CHAT_ID=digits:Numeric chat id
#
# Usage (opt-in):
# source "$(dirname "$0")/../lib/config-ui.sh" 2>/dev/null || source "$(dirname "$0")/config-ui.sh"
# cfg_scopes # list all declared scopes (deduped, sorted)
# cfg_ui <scope> # interactive numbered-menu editor for one scope
CONFIG_DIR="${CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/linux_post_install}"
# common.sh helpers (guarded so the lib is safe if common.sh wasn't loaded)
declare -F log >/dev/null || log() { echo "[+] $*"; }
declare -F warn >/dev/null || warn() { echo "[!] $*"; }
declare -F err >/dev/null || err() { echo "ERROR: $*" >&2; exit 1; }
declare -F ok >/dev/null || ok() { echo " OK $*"; }
_cfg_scope="" # scope being edited (drives the post-write hook)
declare -A _cfg_seen=() # key dedupe registry for cfg_scope_keys
# ── tool directory ─────────────────────────────────────────────────
# Repo layout: lib/config-ui.sh → tools live in ../bin.
# Installed layout: /usr/local/bin/config-ui.sh → ../bin == /usr/local/bin.
cfg_tools_dir() {
local dir f found=0
dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../bin" 2>/dev/null && pwd)"
if [ -n "$dir" ]; then
for f in "$dir"/pos-*; do
[ -x "$f" ] && { found=1; break; }
done
fi
[ "$found" -eq 1 ] && { echo "$dir"; return 0; }
echo "$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
}
# All "# POS_CONFIG:" header lines (prefix stripped), one per line.
# `|| true` keeps pipefail happy when a tool has no such header (grep exits 1).
cfg_headers() {
local dir="$1" f
for f in "$dir"/pos-*; do
[ -f "$f" ] && [ -x "$f" ] || continue
grep '^# POS_CONFIG:' "$f" 2>/dev/null | sed 's/^# POS_CONFIG:[[:space:]]*//' || true
done
return 0
}
# All declared scopes (deduped, sorted).
cfg_scopes() {
local dir line scope
dir="$(cfg_tools_dir)"
cfg_headers "$dir" | while IFS= read -r line; do
scope="${line%%|*}"
scope="${scope// }"
if [ -n "$scope" ]; then
echo "$scope"
fi
done | sort -u
}
# Env-file basename for a scope (first header declaring it wins). Exit 1 if none.
cfg_scope_envfile() {
local scope="$1" dir line s rest env
dir="$(cfg_tools_dir)"
while IFS= read -r line; do
s="${line%%|*}"
s="${s// }"
[ "$s" = "$scope" ] || continue
rest="${line#*|}"
env="${rest%%|*}"
env="${env// }"
echo "$env"
return 0
done < <(cfg_headers "$dir")
return 1
}
# One key field → "KEY|flags|description|example" (deduped via _cfg_seen).
# The optional example is "desc::example" — a literal "::" separates the
# value-format hint from the description.
_cfg_key_line() {
local field="$1" key="" flags="" desc="" rest="" example=""
key="${field%%=*}"
if [[ "$field" == *"="* ]]; then
rest="${field#*=}"
if [[ "$rest" == *":"* ]]; then
flags="${rest%%:*}"
desc="${rest#*:}"
else
flags="$rest"
fi
if [[ "$desc" == *"::"* ]]; then
example="${desc##*::}"
desc="${desc%%::*}"
fi
fi
[ -n "$key" ] || return 0
[ -n "${_cfg_seen[$key]:-}" ] && return 0
_cfg_seen[$key]=1
printf '%s|%s|%s|%s\n' "$key" "$flags" "$desc" "$example"
}
# "*plugins" expansion: keys declared by the installed entertainment
# plugins' "# POS_KEYS:" headers (via lib/entertainment-lib.sh, sourced lazily).
_cfg_plugin_keys() {
declare -F config_keys >/dev/null 2>&1 || {
local lib
for lib in \
"$(dirname "${BASH_SOURCE[0]}")/../lib/entertainment-lib.sh" \
"$(dirname "${BASH_SOURCE[0]}")/entertainment-lib.sh"; do
if [ -f "$lib" ]; then source "$lib"; break; fi
done
}
declare -F config_keys >/dev/null 2>&1 || return 0
local pdir line plugin key desc req
pdir="$(ent_plugin_dir)"
while IFS= read -r line; do
[ -n "$line" ] || continue
IFS='|' read -r plugin key desc req <<<"$line"
[ -n "$key" ] || continue
[ -n "${_cfg_seen[$key]:-}" ] && continue
_cfg_seen[$key]=1
printf '%s|%s|%s (%s) [%s]|\n' "$key" "" "$desc" "$req" "$plugin"
done < <(config_keys "$pdir")
return 0
}
# Declared keys for a scope: "KEY|flags|description" lines, deduped.
cfg_scope_keys() {
local scope="$1" dir line s keystring field
local -a fields=()
dir="$(cfg_tools_dir)"
_cfg_seen=()
while IFS= read -r line; do
s="${line%%|*}"
s="${s// }"
[ "$s" = "$scope" ] || continue
keystring="${line#*|}"
keystring="${keystring#*|}" # drop the env-file field
IFS='|' read -r -a fields <<<"$keystring"
for field in "${fields[@]}"; do
field="${field#"${field%%[![:space:]]*}"}"
field="${field%"${field##*[![:space:]]}"}"
if [ -n "$field" ]; then
if [[ "$field" == "*"* ]]; then
case "$field" in
*plugins*) _cfg_plugin_keys ;;
esac
else
_cfg_key_line "$field"
fi
fi
done
done < <(cfg_headers "$dir")
return 0
}
# Current value of a key in an env file (file is the source of truth, never sourced).
cfg_value() {
local file="$1" key="$2" v
[ -f "$file" ] || return 0
v="$(sed -n "s|^${key}=||p" "$file" | tail -1)"
v="${v//$'\r'/}"
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
printf '%s' "$v"
}
# Write KEY="value" (append or replace) with mkdir -p + chmod 600; a value of
# "-" removes the key's line. Same semantics as write_config_key(). Replaces
# via grep-v + append (not sed), so values may contain &, |, \ etc. safely.
cfg_write() {
local file="$1" key="$2" val="$3" tmp
mkdir -p "$(dirname "$file")"
if [ "$val" = "-" ]; then
[ -f "$file" ] || return 0
tmp="$(mktemp)"
grep -v "^${key}=" "$file" >"$tmp" || true
mv "$tmp" "$file"
chmod 600 "$file"
return 0
fi
val="${val//$'\r'/}"
if [[ "$val" == *$'\n'* ]]; then
val="${val%%$'\n'*}"
warn "multi-line paste — using first line only"
fi
tmp="$(mktemp)"
grep -v "^${key}=" "$file" 2>/dev/null >"$tmp" || true
printf '%s="%s"\n' "$key" "$val" >>"$tmp"
mv "$tmp" "$file"
chmod 600 "$file"
}
# Display a value: masked for secret flags, "(not set)" when empty.
cfg_display() {
local val="$1" flags="$2" n
if [ -z "$val" ]; then
echo "(not set)"
elif [[ ",$flags," == *,secret,* ]]; then
n=${#val}
if [ "$n" -le 12 ]; then
echo "${val:0:4}... (${n} chars)"
else
echo "${val:0:6}...${val: -4} (${n} chars)"
fi
else
echo "$val"
fi
}
# Validate a value against a key's flags. Prints an error, returns 1 on failure.
cfg_validate() {
local flags="$1" val="$2"
case ",$flags," in
*,digits,*) [[ "$val" =~ ^-?[0-9]+$ ]] || { echo "must be digits only (a leading '-' is allowed for group/supergroup ids)"; return 1; } ;;
esac
case ",$flags," in
*,num,*) [[ "$val" =~ ^-?[0-9]+$ ]] || { echo "must be an integer"; return 1; } ;;
esac
case ",$flags," in
*,float,*) [[ "$val" =~ ^-?[0-9]+([.][0-9]+)?$ ]] || { echo "must be a number"; return 1; } ;;
esac
return 0
}
# Read a value with echo off (secret flags). Falls back to plain read when
# stdin is not a TTY (e.g. piped menu input).
# The cursor-advancing newline after the hidden input MUST go to the terminal
# (>&2), not stdout — cfg_read_secret is called via $(), so anything on stdout
# is captured into the value; a stray leading newline corrupted ai.env.
cfg_read_secret() {
local val echo_off=0
if [ -t 0 ]; then
stty -echo 2>/dev/null && echo_off=1
fi
read -r val || true
if [ "$echo_off" -eq 1 ]; then
stty echo 2>/dev/null || true
echo >&2
fi
printf '%s' "$val"
}
# After a successful write: the entertainment scope re-syncs its auto-trigger
# timers when the ENABLED list changes (mirrors `pos entertainment config set`).
_cfg_post_write() {
local key="$1"
[ "$_cfg_scope" = "entertainment" ] && [ "$key" = "ENABLED" ] || return 0
declare -F sync_timers >/dev/null 2>&1 && sync_timers
}
# Edit one key: Enter keeps the current value, "-" clears it.
_cfg_edit_one() {
local file="$1" keystr="$2"
local key flags desc example cur val errmsg hint
IFS='|' read -r key flags desc example <<<"$keystr"
cur="$(cfg_value "$file" "$key")"
hint="${example:+ (e.g. ${example})}"
if [[ ",$flags," == *,secret,* ]]; then
printf ' %s [%s]%s (input hidden): ' "$key" "$(cfg_display "$cur" "$flags")" "$hint"
val="$(cfg_read_secret)"
else
read -rp " ${key}${hint} [${cur:-unset}]: " val || return 0
fi
if [ -z "$val" ]; then
log "kept current value for $key"
return 0
fi
if [ "$val" = "-" ]; then
cfg_write "$file" "$key" "-"
log "cleared $key from $file"
_cfg_post_write "$key"
return 0
fi
if ! errmsg="$(cfg_validate "$flags" "$val")"; then
warn "$key: $errmsg — not saved"
return 0
fi
cfg_write "$file" "$key" "$val"
ok "$key saved to $file"
_cfg_post_write "$key"
}
# Interactive numbered-menu editor for one scope. q quits; r re-renders.
cfg_ui() {
local scope="$1" envfile file line
envfile="$(cfg_scope_envfile "$scope")" || { warn "unknown config scope '$scope'"; return 1; }
file="$CONFIG_DIR/$envfile"
_cfg_scope="$scope"
local -a keys=()
while IFS= read -r line; do
if [ -n "$line" ]; then
keys+=("$line")
fi
done < <(cfg_scope_keys "$scope")
if [ ${#keys[@]} -eq 0 ]; then
warn "no config keys declared for scope '$scope'"
return 1
fi
local choice i k f d e v
while true; do
echo
echo "pos config — ${scope} (${envfile})"
echo "------------------------------------"
i=0
for line in "${keys[@]}"; do
i=$((i + 1))
IFS='|' read -r k f d e <<<"$line"
v="$(cfg_value "$file" "$k")"
printf ' %2d) %-28s %s\n' "$i" "$k" "$(cfg_display "$v" "$f")"
if [ -n "$d" ]; then
printf ' %s\n' "$d"
fi
if [ -n "$e" ]; then
printf ' e.g. %s\n' "$e"
fi
done
echo
read -rp "Variable number [q to quit]: " choice || { echo; return 0; }
case "$choice" in
q|Q|quit|exit) echo; return 0 ;;
r|R|refresh) continue ;;
"") continue ;;
*)
if [[ "$choice" =~ ^[0-9]+$ ]] && (( choice >= 1 && choice <= ${#keys[@]} )); then
_cfg_edit_one "$file" "${keys[$((choice - 1))]}"
else
warn "invalid number '$choice' (1-${#keys[@]})"
fi
;;
esac
done
}