From dd3558ce2e30f94f553b809e8dd719a10e344be3 Mon Sep 17 00:00:00 2001 From: Algirdas Date: Sat, 4 Jul 2026 19:31:03 +0300 Subject: [PATCH 1/6] fix(install): read prompts from /dev/tty so curl | bash works When piped via 'curl ... | sudo bash', stdin is the script text, not the terminal. Interactive read calls returned EOF immediately, and under 'set -e' the script exited silently after creating the install dir. Fix: redirect all read calls to /dev/tty. Add a guard that fails with a clear message when no terminal is available. --- install.sh | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/install.sh b/install.sh index bcb5c953..e65b26ed 100755 --- a/install.sh +++ b/install.sh @@ -31,7 +31,7 @@ ask() { # ask local var="$1" prompt="$2" default="$3" printf " ${BOLD}%s${NC} ${DIM}[%s]${NC}: " "$prompt" "$default" - read -r input + read -r input < /dev/tty eval "$var=\"${input:-$default}\"" } @@ -39,7 +39,7 @@ ask_secret() { # ask_secret local var="$1" prompt="$2" printf " ${BOLD}%s${NC}: " "$prompt" - read -rs input + read -rs input < /dev/tty printf "\n" eval "$var=\"$input\"" } @@ -50,7 +50,7 @@ confirm() { local hint [ "$default" = "y" ] && hint="Y/n" || hint="y/N" printf " ${BOLD}%s${NC} ${DIM}[%s]${NC}: " "$prompt" "$hint" - read -r answer + read -r answer < /dev/tty answer="${answer:-$default}" [[ "$answer" =~ ^[Yy] ]] } @@ -294,6 +294,14 @@ main() { printf "${BOLD} ╚═══════════════════════════════════╝${NC}\n" printf "\n" + # Ensure we can read interactive input even when piped (curl | bash) + if [ ! -t 0 ] && [ ! -r /dev/tty ]; then + error "This installer is interactive and needs a terminal." + printf "\n Run it like this instead:\n" + printf " ${BOLD}bash <(curl -fsSL ${COMPOSE_URL%/docker-compose.yml}/install.sh)${NC}\n\n" + exit 1 + fi + check_root check_docker collect_config From 97674f0701b21d8a0bd1c938afd3e62f558a5792 Mon Sep 17 00:00:00 2001 From: Algirdas Date: Sat, 4 Jul 2026 19:34:47 +0300 Subject: [PATCH 2/6] fix(install): loud error trap, drop set -e, guard mkdir/cd Script exited silently after collecting config. Root causes: - set -e killed the script on any non-zero return with no message - mkdir/cd failures were not reported Changes: - Drop 'set -e' (keep 'set -uo pipefail'), add EXIT trap that prints a visible message on non-zero exit - Explicit error handling around mkdir and cd with actionable hints --- install.sh | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/install.sh b/install.sh index e65b26ed..5a31319d 100755 --- a/install.sh +++ b/install.sh @@ -1,11 +1,14 @@ #!/usr/bin/env bash -set -euo pipefail +set -uo pipefail # ───────────────────────────────────────────── # Expensave Installer # https://github.com/algirdasc/expensave # ───────────────────────────────────────────── +# Fail loudly on unexpected errors instead of exiting silently +trap 'code=$?; if [ $code -ne 0 ]; then printf "\n \033[0;31m✗\033[0m Installer stopped (exit %s). See the last message above.\n\n" "$code" >&2; fi' EXIT + REPO="algirdasc/expensave" COMPOSE_URL="https://raw.githubusercontent.com/${REPO}/main/docker-compose.yml" DEFAULT_INSTALL_DIR="/opt/expensave" @@ -179,8 +182,14 @@ do_install() { # Create install dir info "Creating directory: ${INSTALL_DIR}" - mkdir -p "$INSTALL_DIR" - cd "$INSTALL_DIR" + if ! mkdir -p "$INSTALL_DIR"; then + error "Could not create ${INSTALL_DIR}. Try running with sudo." + exit 1 + fi + if ! cd "$INSTALL_DIR"; then + error "Could not enter ${INSTALL_DIR}." + exit 1 + fi # Generate secrets local db_password From e460588eaf0e5dc43baafa1d66465ae96a7e6744 Mon Sep 17 00:00:00 2001 From: Algirdas Date: Sat, 4 Jul 2026 19:37:54 +0300 Subject: [PATCH 3/6] fix(install): re-exec from file under curl|bash + safe gen_password Two root causes for the script exiting silently after prompts: 1. gen_password piped /dev/urandom into 'head -c', which closes the pipe early and raises SIGPIPE, killing the pipeline (and script). Fix: read a fixed 4096-byte chunk via process substitution, then cut. 2. Under 'curl ... | bash' the script body lives on stdin. After the last /dev/tty read, bash had no more script to read and exited. Fix: when stdin is not a TTY, re-download to a temp file and re-exec with 'bash $tmp < /dev/tty' (guarded by EXPENSAVE_REEXEC). --- install.sh | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/install.sh b/install.sh index 5a31319d..df3d82a3 100755 --- a/install.sh +++ b/install.sh @@ -6,13 +6,28 @@ set -uo pipefail # https://github.com/algirdasc/expensave # ───────────────────────────────────────────── -# Fail loudly on unexpected errors instead of exiting silently -trap 'code=$?; if [ $code -ne 0 ]; then printf "\n \033[0;31m✗\033[0m Installer stopped (exit %s). See the last message above.\n\n" "$code" >&2; fi' EXIT - REPO="algirdasc/expensave" +SCRIPT_URL="https://raw.githubusercontent.com/${REPO}/main/install.sh" COMPOSE_URL="https://raw.githubusercontent.com/${REPO}/main/docker-compose.yml" DEFAULT_INSTALL_DIR="/opt/expensave" +# When run via 'curl ... | bash', stdin is the script text. Interactive +# reads and stdin-consuming commands then behave unpredictably. Re-exec +# from a real file so the script body no longer lives on stdin. +if [ ! -t 0 ] && [ -z "${EXPENSAVE_REEXEC:-}" ]; then + tmp="$(mktemp)" + if curl -fsSL "$SCRIPT_URL" -o "$tmp"; then + export EXPENSAVE_REEXEC=1 + exec bash "$tmp" "$@" < /dev/tty + else + printf "Failed to download installer from %s\n" "$SCRIPT_URL" >&2 + exit 1 + fi +fi + +# Fail loudly on unexpected errors instead of exiting silently +trap 'code=$?; if [ $code -ne 0 ]; then printf "\n \033[0;31m✗\033[0m Installer stopped (exit %s). See the last message above.\n\n" "$code" >&2; fi' EXIT + # ── Colors ──────────────────────────────────── RED='\033[0;31m' GREEN='\033[0;32m' @@ -59,8 +74,10 @@ confirm() { } gen_password() { - # 20 char alphanumeric - tr -dc 'A-Za-z0-9' Date: Sat, 4 Jul 2026 19:54:46 +0300 Subject: [PATCH 4/6] fix(install): re-exec by copying stdin, not re-downloading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous re-exec fetched install.sh from main, so testing a branch would run the OLD main version — masking the fix and still dying in the old gen_password. Instead, copy this very script from stdin to a temp file ('cat > $tmp') and re-exec it with /dev/tty as stdin. No re-download, no version drift. --- install.sh | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/install.sh b/install.sh index df3d82a3..21b2ae99 100755 --- a/install.sh +++ b/install.sh @@ -7,22 +7,19 @@ set -uo pipefail # ───────────────────────────────────────────── REPO="algirdasc/expensave" -SCRIPT_URL="https://raw.githubusercontent.com/${REPO}/main/install.sh" COMPOSE_URL="https://raw.githubusercontent.com/${REPO}/main/docker-compose.yml" DEFAULT_INSTALL_DIR="/opt/expensave" -# When run via 'curl ... | bash', stdin is the script text. Interactive -# reads and stdin-consuming commands then behave unpredictably. Re-exec -# from a real file so the script body no longer lives on stdin. +# When run via 'curl ... | bash', the script body is fed through stdin. +# After the first interactive read the remaining script may be lost and +# bash exits early. Copy *this same script* (from stdin) to a temp file +# and re-exec it with the terminal as stdin — no re-download, so the +# version never drifts. if [ ! -t 0 ] && [ -z "${EXPENSAVE_REEXEC:-}" ]; then tmp="$(mktemp)" - if curl -fsSL "$SCRIPT_URL" -o "$tmp"; then - export EXPENSAVE_REEXEC=1 - exec bash "$tmp" "$@" < /dev/tty - else - printf "Failed to download installer from %s\n" "$SCRIPT_URL" >&2 - exit 1 - fi + cat > "$tmp" + export EXPENSAVE_REEXEC=1 + exec bash "$tmp" "$@" < /dev/tty fi # Fail loudly on unexpected errors instead of exiting silently From 46ba961568704fe0b8bf004c0940b52d940b7f8b Mon Sep 17 00:00:00 2001 From: Algirdas Date: Sat, 4 Jul 2026 19:58:15 +0300 Subject: [PATCH 5/6] debug(install): add breadcrumbs + simplify gen_password/re-exec - gen_password: no pipes/process-substitution, transform in-memory - re-exec: drop $@ passthrough, add debug markers - add [debug] marker between collect_config and do_install to pinpoint where it stops --- install.sh | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/install.sh b/install.sh index 21b2ae99..4d6f4647 100755 --- a/install.sh +++ b/install.sh @@ -18,9 +18,11 @@ DEFAULT_INSTALL_DIR="/opt/expensave" if [ ! -t 0 ] && [ -z "${EXPENSAVE_REEXEC:-}" ]; then tmp="$(mktemp)" cat > "$tmp" + printf '[debug] re-exec from %s\n' "$tmp" >&2 export EXPENSAVE_REEXEC=1 - exec bash "$tmp" "$@" < /dev/tty + exec bash "$tmp" < /dev/tty fi +[ -n "${EXPENSAVE_REEXEC:-}" ] && printf '[debug] running re-exec copy\n' >&2 # Fail loudly on unexpected errors instead of exiting silently trap 'code=$?; if [ $code -ne 0 ]; then printf "\n \033[0;31m✗\033[0m Installer stopped (exit %s). See the last message above.\n\n" "$code" >&2; fi' EXIT @@ -71,10 +73,11 @@ confirm() { } gen_password() { - # 20 char alphanumeric — read a fixed chunk first to avoid SIGPIPE - # (piping /dev/urandom straight into head closes the pipe early and - # can kill the whole script when running under 'curl | bash') - LC_ALL=C tr -dc 'A-Za-z0-9' < <(head -c 4096 /dev/urandom) | cut -c1-20 + # 20 char alphanumeric. Read a fixed random chunk into a var first, + # then transform in-memory — no pipes, no SIGPIPE, no subshell quirks. + local raw + raw=$(head -c 4096 /dev/urandom | LC_ALL=C tr -dc 'A-Za-z0-9') + printf '%s' "${raw:0:20}" } detect_timezone() { @@ -328,6 +331,7 @@ main() { check_root check_docker collect_config + printf "\n ${DIM}[debug] config collected, starting install...${NC}\n" do_install print_summary } From bd0fc9af2af8069d3d01a3f0d677491a7d5259d6 Mon Sep 17 00:00:00 2001 From: Algirdas Date: Sat, 4 Jul 2026 20:02:43 +0300 Subject: [PATCH 6/6] fix(install): drop broken re-exec, rely on /dev/tty reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-exec copied stdin to a temp file, but bash had ALREADY consumed the first ~19 lines (including COMPOSE_URL and DEFAULT_INSTALL_DIR definitions) before 'cat > tmp' ran. The re-exec'd copy was missing those vars — hence empty install dir and 'curl: (3) bad URL'. Fix: - Remove re-exec entirely. Interactive reads already use /dev/tty, which works fine under 'curl | bash'. - Restore 'set -e' so failed curl/sed/docker commands stop the script instead of printing a fake success checkmark. - Keep a trap for a clear failure message. --- install.sh | 32 ++++++++------------------------ 1 file changed, 8 insertions(+), 24 deletions(-) diff --git a/install.sh b/install.sh index 4d6f4647..cea9bccd 100755 --- a/install.sh +++ b/install.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -set -uo pipefail +set -euo pipefail # ───────────────────────────────────────────── # Expensave Installer @@ -10,22 +10,15 @@ REPO="algirdasc/expensave" COMPOSE_URL="https://raw.githubusercontent.com/${REPO}/main/docker-compose.yml" DEFAULT_INSTALL_DIR="/opt/expensave" -# When run via 'curl ... | bash', the script body is fed through stdin. -# After the first interactive read the remaining script may be lost and -# bash exits early. Copy *this same script* (from stdin) to a temp file -# and re-exec it with the terminal as stdin — no re-download, so the -# version never drifts. -if [ ! -t 0 ] && [ -z "${EXPENSAVE_REEXEC:-}" ]; then - tmp="$(mktemp)" - cat > "$tmp" - printf '[debug] re-exec from %s\n' "$tmp" >&2 - export EXPENSAVE_REEXEC=1 - exec bash "$tmp" < /dev/tty +# Interactive prompts read from /dev/tty (see ask/confirm), so the script +# works even when piped via 'curl ... | bash'. Require a usable terminal. +if [ ! -r /dev/tty ]; then + printf "This installer is interactive and needs a terminal (/dev/tty).\n" >&2 + exit 1 fi -[ -n "${EXPENSAVE_REEXEC:-}" ] && printf '[debug] running re-exec copy\n' >&2 -# Fail loudly on unexpected errors instead of exiting silently -trap 'code=$?; if [ $code -ne 0 ]; then printf "\n \033[0;31m✗\033[0m Installer stopped (exit %s). See the last message above.\n\n" "$code" >&2; fi' EXIT +# Surface unexpected failures instead of a bare non-zero exit +trap 'code=$?; if [ $code -ne 0 ]; then printf "\n \033[0;31m✗ Installer failed (exit %s). See the message above.\033[0m\n\n" "$code" >&2; fi' EXIT # ── Colors ──────────────────────────────────── RED='\033[0;31m' @@ -320,18 +313,9 @@ main() { printf "${BOLD} ╚═══════════════════════════════════╝${NC}\n" printf "\n" - # Ensure we can read interactive input even when piped (curl | bash) - if [ ! -t 0 ] && [ ! -r /dev/tty ]; then - error "This installer is interactive and needs a terminal." - printf "\n Run it like this instead:\n" - printf " ${BOLD}bash <(curl -fsSL ${COMPOSE_URL%/docker-compose.yml}/install.sh)${NC}\n\n" - exit 1 - fi - check_root check_docker collect_config - printf "\n ${DIM}[debug] config collected, starting install...${NC}\n" do_install print_summary }