This is an automated email from the ASF dual-hosted git repository.
wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git
The following commit(s) were added to refs/heads/main by this push:
new 962d5871 [tools]Import Wizard for Installation Setup (#599)
962d5871 is described below
commit 962d58713ac5237269f895fdc5e9815edf4e4102
Author: Jinkun Liu <[email protected]>
AuthorDate: Fri May 22 22:48:20 2026 +0800
[tools]Import Wizard for Installation Setup (#599)
Co-authored-by: WenjinXie <[email protected]>
---
.github/workflows/ci.yml | 15 +
tools/.rat-excludes | 4 +
tools/install.sh | 1886 ++++++++++++++++++++
tools/test/.gitignore | 1 +
tools/test/helpers/load.bash | 50 +
tools/test/helpers/shim.bash | 88 +
tools/test/integration/.gitkeep | 0
tools/test/integration/bootstrap_gum_temp.bats | 165 ++
tools/test/integration/download_file.bats | 88 +
tools/test/integration/dry_run.bats | 54 +
tools/test/integration/dry_run_extra.bats | 42 +
tools/test/integration/err_trap.bats | 39 +
tools/test/integration/help.bats | 16 +
.../test/integration/install_flink_agents_jar.bats | 98 +
.../test/integration/install_flink_if_needed.bats | 132 ++
tools/test/integration/venv_dir_validation.bats | 61 +
tools/test/run.sh | 53 +
.../test/unit/detect_flink_version_from_home.bats | 114 ++
tools/test/unit/edit_plan_back.bats | 69 +
tools/test/unit/edit_plan_quote.bats | 88 +
tools/test/unit/is_snapshot_version.bats | 43 +
tools/test/unit/is_valid_tgz.bats | 44 +
tools/test/unit/mark_explicit.bats | 31 +
tools/test/unit/normalize_path.bats | 64 +
tools/test/unit/parse_args.bats | 82 +
tools/test/unit/platform_detect.bats | 98 +
tools/test/unit/shim_self_test.bats | 50 +
tools/test/unit/show_install_plan.bats | 74 +
tools/test/unit/smoke.bats | 18 +
tools/test/unit/ui_helpers.bats | 54 +
tools/test/unit/validate_python_bin.bats | 73 +
tools/test/unit/validate_venv_dir.bats | 83 +
32 files changed, 3777 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 484f0e48..198c1452 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -48,6 +48,21 @@ jobs:
- name: Check code style
run: ./tools/lint.sh -c
+ install_sh_tests:
+ name: install.sh tests (${{ matrix.os }})
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, macos-latest]
+ steps:
+ - uses: actions/checkout@v4
+ - name: Install bash 4+ (macOS)
+ if: runner.os == 'macOS'
+ run: brew install bash
+ - name: Run install.sh tests
+ run: bash tools/test/run.sh
+
build_backend_tests:
name: ut-build-backend
runs-on: ubuntu-latest
diff --git a/tools/.rat-excludes b/tools/.rat-excludes
index dceb40d3..11e47ca7 100644
--- a/tools/.rat-excludes
+++ b/tools/.rat-excludes
@@ -4,6 +4,10 @@ docs
.git
.gitignore
.gitmodules
+\.bats-cache
+.*\.bats$
+.*\.bash$
+\.gitkeep
.*\.lock$
.venv/*
.idea/*
diff --git a/tools/install.sh b/tools/install.sh
new file mode 100755
index 00000000..c71e3fa3
--- /dev/null
+++ b/tools/install.sh
@@ -0,0 +1,1886 @@
+#!/usr/bin/env bash
+################################################################################
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+
+# -E (errtrace) propagates the ERR trap into functions and command
+# substitutions so the failure banner fires from anywhere in the script,
+# not only at the top level.
+set -Eeuo pipefail
+
+BOLD='\033[1m'
+ACCENT='\033[38;2;255;77;77m' # coral-bright
+INFO='\033[38;2;136;146;176m' # text-secondary
+SUCCESS='\033[38;2;0;229;204m' # cyan-bright
+WARN='\033[38;2;255;176;32m' # amber
+ERROR='\033[38;2;230;57;70m' # coral-mid
+MUTED='\033[38;2;90;100;128m' # text-muted
+NC='\033[0m' # No Color
+
+TMPFILES=()
+cleanup_tmpfiles() {
+ local f
+ for f in "${TMPFILES[@]:-}"; do
+ rm -rf "$f" 2>/dev/null || true
+ done
+}
+trap cleanup_tmpfiles EXIT
+# Some interactive read combinations (notably `read -e` under stdin
redirection)
+# can swallow SIGINT, leaving the user pressing Ctrl+C with no effect. Install
+# an explicit INT trap so Ctrl+C always lands.
+trap 'die_cancelled' INT
+
+mktempfile() {
+ local f
+ f="$(mktemp)"
+ TMPFILES+=("$f")
+ echo "$f"
+}
+
+DOWNLOADER=""
+detect_downloader() {
+ if command -v curl &> /dev/null; then
+ DOWNLOADER="curl"
+ return 0
+ fi
+ if command -v wget &> /dev/null; then
+ DOWNLOADER="wget"
+ return 0
+ fi
+ ui_error "Missing downloader (curl or wget required)"
+ exit 1
+}
+
+download_file() {
+ local url="$1"
+ local output="$2"
+ if [[ -z "$DOWNLOADER" ]]; then
+ detect_downloader
+ fi
+ if [[ "$DOWNLOADER" == "curl" ]]; then
+ curl -fL --progress-bar --proto '=https' --tlsv1.2 --retry 3
--max-time 900 --retry-delay 1 --retry-connrefused -o "$output" "$url"
+ return
+ fi
+ wget -q --show-progress --https-only --secure-protocol=TLSv1_2 --tries=3
--timeout=900 -O "$output" "$url"
+}
+
+GUM_VERSION="${FLINK_AGENTS_GUM_VERSION:-0.17.0}"
+GUM=""
+GUM_STATUS="skipped"
+GUM_REASON=""
+# Persistent cache for the auto-downloaded gum binary so reruns don't
+# re-download it. Honors XDG_CACHE_HOME, falls back to ~/.cache.
+GUM_CACHE_ROOT="${FLINK_AGENTS_GUM_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/flink-agents/gum}"
+
+is_non_interactive_shell() {
+ if [[ "${NO_PROMPT:-0}" == "1" ]]; then
+ return 0
+ fi
+ if [[ ! -t 0 || ! -t 1 ]]; then
+ return 0
+ fi
+ return 1
+}
+
+gum_is_tty() {
+ if [[ -n "${NO_COLOR:-}" ]]; then
+ return 1
+ fi
+ if [[ "${TERM:-dumb}" == "dumb" ]]; then
+ return 1
+ fi
+ if [[ -t 2 || -t 1 ]]; then
+ return 0
+ fi
+ if [[ -r /dev/tty && -w /dev/tty ]]; then
+ return 0
+ fi
+ return 1
+}
+
+gum_detect_os() {
+ case "$(uname -s 2>/dev/null || true)" in
+ Darwin) echo "Darwin" ;;
+ Linux) echo "Linux" ;;
+ *) echo "unsupported" ;;
+ esac
+}
+
+gum_detect_arch() {
+ case "$(uname -m 2>/dev/null || true)" in
+ x86_64|amd64) echo "x86_64" ;;
+ arm64|aarch64) echo "arm64" ;;
+ i386|i686) echo "i386" ;;
+ armv7l|armv7) echo "armv7" ;;
+ armv6l|armv6) echo "armv6" ;;
+ *) echo "unknown" ;;
+ esac
+}
+
+verify_sha256sum_file() {
+ local checksums="$1"
+ if command -v sha256sum >/dev/null 2>&1; then
+ sha256sum --ignore-missing -c "$checksums" >/dev/null 2>&1
+ return $?
+ fi
+ if command -v shasum >/dev/null 2>&1; then
+ shasum -a 256 --ignore-missing -c "$checksums" >/dev/null 2>&1
+ return $?
+ fi
+ return 1
+}
+
+bootstrap_gum_temp() {
+ GUM=""
+ GUM_STATUS="skipped"
+ GUM_REASON=""
+
+ if is_non_interactive_shell; then
+ GUM_REASON="non-interactive shell (auto-disabled)"
+ return 1
+ fi
+
+ if ! gum_is_tty; then
+ GUM_REASON="terminal does not support gum UI"
+ return 1
+ fi
+
+ if command -v gum >/dev/null 2>&1; then
+ GUM="gum"
+ GUM_STATUS="found"
+ GUM_REASON="already installed"
+ return 0
+ fi
+
+ local cache_dir="${GUM_CACHE_ROOT}/${GUM_VERSION}"
+ local cached_gum="${cache_dir}/gum"
+ if [[ -x "$cached_gum" ]]; then
+ GUM="$cached_gum"
+ GUM_STATUS="cached"
+ GUM_REASON="reused from ${cache_dir}"
+ return 0
+ fi
+
+ if ! command -v tar >/dev/null 2>&1; then
+ GUM_REASON="tar not found"
+ return 1
+ fi
+
+ local os arch asset base gum_tmpdir extracted_path
+ os="$(gum_detect_os)"
+ arch="$(gum_detect_arch)"
+ if [[ "$os" == "unsupported" || "$arch" == "unknown" ]]; then
+ GUM_REASON="unsupported os/arch ($os/$arch)"
+ return 1
+ fi
+
+ asset="gum_${GUM_VERSION}_${os}_${arch}.tar.gz"
+
base="https://github.com/charmbracelet/gum/releases/download/v${GUM_VERSION}"
+
+ gum_tmpdir="$(mktemp -d)"
+ TMPFILES+=("$gum_tmpdir")
+
+ ui_info "Installing gum v${GUM_VERSION}, please wait..."
+
+ if ! download_file "${base}/${asset}" "$gum_tmpdir/$asset"; then
+ GUM_REASON="download failed"
+ return 1
+ fi
+
+ if ! download_file "${base}/checksums.txt" "$gum_tmpdir/checksums.txt";
then
+ GUM_REASON="checksum unavailable or failed"
+ return 1
+ fi
+
+ if ! (cd "$gum_tmpdir" && verify_sha256sum_file "checksums.txt"); then
+ GUM_REASON="checksum unavailable or failed"
+ return 1
+ fi
+
+ if ! tar -xzf "$gum_tmpdir/$asset" -C "$gum_tmpdir" >/dev/null 2>&1; then
+ GUM_REASON="extract failed"
+ return 1
+ fi
+
+ extracted_path="$(find "$gum_tmpdir" -type f -name gum 2>/dev/null | head
-n1 || true)"
+ if [[ -z "$extracted_path" ]]; then
+ GUM_REASON="gum binary missing after extract"
+ return 1
+ fi
+
+ chmod +x "$extracted_path" >/dev/null 2>&1 || true
+ if [[ ! -x "$extracted_path" ]]; then
+ GUM_REASON="gum binary is not executable"
+ return 1
+ fi
+
+ # Promote into the persistent cache so subsequent runs skip the download.
+ if mkdir -p "$cache_dir" 2>/dev/null && mv "$extracted_path" "$cached_gum"
2>/dev/null; then
+ GUM="$cached_gum"
+ GUM_REASON="cached at ${cache_dir}"
+ else
+ # Cache write failed (read-only HOME etc.) — fall back to using the
+ # extracted binary directly. It'll get cleaned up at EXIT, costing a
+ # re-download next run, but the current run still works.
+ GUM="$extracted_path"
+ GUM_REASON="temp, verified (cache unavailable)"
+ fi
+ GUM_STATUS="installed"
+ return 0
+}
+
+print_gum_status() {
+ case "$GUM_STATUS" in
+ found)
+ ui_success "gum available (${GUM_REASON})"
+ ;;
+ cached)
+ ui_success "gum loaded from cache (v${GUM_VERSION})"
+ ;;
+ installed)
+ ui_success "gum bootstrapped (${GUM_REASON}, v${GUM_VERSION})"
+ ;;
+ *)
+ if [[ -n "$GUM_REASON" && "$GUM_REASON" != "non-interactive shell
(auto-disabled)" ]]; then
+ ui_info "gum skipped (${GUM_REASON})"
+ fi
+ ;;
+ esac
+}
+
+print_installer_banner() {
+ if [[ -n "$GUM" ]]; then
+ local title
+ title="$("$GUM" style --foreground "#ff4d4d" --bold "Apache Flink
Agents Installer")"
+ "$GUM" style --border rounded --border-foreground "#ff4d4d" --padding
"1 2" "$title"
+ echo ""
+ return
+ fi
+
+ echo -e "${ACCENT}${BOLD}"
+ echo " Apache Flink Agents Installer"
+ echo ""
+}
+
+detect_os_or_die() {
+ OS="unknown"
+ if [[ "$OSTYPE" == "darwin"* ]]; then
+ OS="macos"
+ elif [[ "$OSTYPE" == "linux-gnu"* ]] || [[ -n "${WSL_DISTRO_NAME:-}" ]];
then
+ OS="linux"
+ fi
+
+ if [[ "$OS" == "unknown" ]]; then
+ ui_error "Unsupported operating system"
+ echo "This installer supports macOS and Linux (including WSL)."
+ exit 1
+ fi
+
+ ui_success "Detected: $OS"
+}
+
+ui_info() {
+ local msg="$*"
+ if [[ -n "$GUM" ]]; then
+ "$GUM" log --level info "$msg"
+ else
+ echo -e "${MUTED}·${NC} ${msg}"
+ fi
+}
+
+ui_warn() {
+ local msg="$*"
+ if [[ -n "$GUM" ]]; then
+ "$GUM" log --level warn "$msg"
+ else
+ echo -e "${WARN}!${NC} ${msg}"
+ fi
+}
+
+ui_success() {
+ local msg="$*"
+ if [[ -n "$GUM" ]]; then
+ local mark
+ mark="$("$GUM" style --foreground "#00e5cc" --bold "✓")"
+ echo "${mark} ${msg}"
+ else
+ echo -e "${SUCCESS}✓${NC} ${msg}"
+ fi
+}
+
+ui_error() {
+ local msg="$*"
+ if [[ -n "$GUM" ]]; then
+ "$GUM" log --level error "$msg"
+ else
+ echo -e "${ERROR}x${NC} ${msg}"
+ fi
+}
+
+die() {
+ ui_error "$*"
+ exit 1
+}
+
+die_cancelled() {
+ # Write to stderr so the message survives `$(...)` command substitution
+ # — otherwise the cancellation note would be silently captured by the
+ # caller's variable and the user would see nothing on Ctrl+C.
+ ui_info "Cancelled by user" >&2
+ exit 130
+}
+
+# Fires on any command that exits non-zero under set -e — the cases that
+# would otherwise dump a tail of pip / curl / tar noise and silently exit.
+# `die`/`die_cancelled` use plain `exit` (not a non-zero command), so they
+# do NOT trip this trap; they print their own friendly message and exit
+# straight away. That keeps the banner reserved for genuinely unexpected
+# failures.
+on_error() {
+ local rc=$1
+ local line=$2
+ local cmd=$3
+ # Suppress ourselves if we re-enter (e.g. echo failing under set -e
+ # inside the handler itself).
+ trap - ERR
+ {
+ echo ""
+ echo
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ if (( INSTALL_STAGE_CURRENT > 0 )); then
+ printf '%b Installation failed at stage %d/%d (%s).\n' \
+ "${ERROR}✗${NC}" \
+ "$INSTALL_STAGE_CURRENT" "$INSTALL_STAGE_TOTAL" \
+ "$INSTALL_STAGE_TITLE"
+ else
+ printf '%b Installation failed.\n' "${ERROR}✗${NC}"
+ fi
+ echo ""
+ echo " Command: ${cmd}"
+ echo " Source: install.sh:${line}"
+ echo " Exit code: ${rc}"
+ echo ""
+ echo " Re-run with --verbose for full output, or report at:"
+ echo " https://github.com/apache/flink-agents/issues"
+ echo
"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
+ } >&2
+ exit "$rc"
+}
+trap 'on_error $? $LINENO "$BASH_COMMAND"' ERR
+
+INSTALL_STAGE_TOTAL=5
+INSTALL_STAGE_CURRENT=0
+INSTALL_STAGE_TITLE=""
+
+ui_section() {
+ local title="$1"
+ if [[ -n "$GUM" ]]; then
+ "$GUM" style --bold --foreground "#ff4d4d" --padding "1 0" "$title"
+ else
+ echo ""
+ echo -e "${ACCENT}${BOLD}${title}${NC}"
+ fi
+}
+
+ui_stage() {
+ local title="$1"
+ INSTALL_STAGE_CURRENT=$((INSTALL_STAGE_CURRENT + 1))
+ # Remember the stage title so on_error can include it in the
+ # failure banner ("Installation failed at stage 3/5 (Installing
+ # Apache Flink)").
+ INSTALL_STAGE_TITLE="$title"
+ ui_section "[${INSTALL_STAGE_CURRENT}/${INSTALL_STAGE_TOTAL}] ${title}"
+}
+
+UI_KV_KEY_WIDTH=24
+
+ui_kv() {
+ local key="$1"
+ local value="$2"
+ local labeled="${key}:"
+ if [[ -n "$GUM" ]]; then
+ local key_part value_part
+ key_part="$("$GUM" style --foreground "#5a6480" --width
"$UI_KV_KEY_WIDTH" "$labeled")"
+ value_part="$("$GUM" style --bold "$value")"
+ "$GUM" join --horizontal "$key_part" "$value_part"
+ else
+ printf "${MUTED}%-${UI_KV_KEY_WIDTH}s${NC} %s\n" "$labeled" "$value"
+ fi
+}
+
+ui_celebrate() {
+ local msg="$1"
+ if [[ -n "$GUM" ]]; then
+ "$GUM" style --bold --foreground "#00e5cc" "$msg"
+ else
+ echo -e "${SUCCESS}${BOLD}${msg}${NC}"
+ fi
+}
+
+mark_explicit() {
+ local var="$1"
+ if [[ -n "${!var:-}" ]]; then
+ printf -v "${var}_EXPLICIT" '%s' '1'
+ else
+ printf -v "${var}_EXPLICIT" '%s' '0'
+ fi
+}
+
+mark_explicit FLINK_VERSION
+mark_explicit FLINK_AGENTS_VERSION
+mark_explicit INSTALL_DIR
+mark_explicit VENV_DIR
+
+FLINK_VERSION="${FLINK_VERSION:-2.2.0}"
+FLINK_AGENTS_VERSION="${FLINK_AGENTS_VERSION:-0.2.1}"
+FLINK_SCALA_VERSION="${FLINK_SCALA_VERSION:-2.12}"
+FLINK_BASE_URL="${FLINK_BASE_URL:-https://dlcdn.apache.org/flink}"
+# Flink Agents JARs live next to Flink on the ASF mirror network. Override
+# this to point at archive.apache.org if you need a non-current release.
+FLINK_AGENTS_BASE_URL="${FLINK_AGENTS_BASE_URL:-https://dlcdn.apache.org/flink}"
+# Direct (non-mirrored) ASF download host. We hit it for the SHA512 sidecar
+# because the mirror network does not redistribute checksum files.
+FLINK_AGENTS_CHECKSUM_BASE_URL="${FLINK_AGENTS_CHECKSUM_BASE_URL:-https://downloads.apache.org/flink}"
+
+FLINK_SUPPORTED_VERSIONS=("2.2.0" "2.1.1" "2.0.1" "1.20.3")
+FLINK_RECOMMENDED_VERSION="2.2.0"
+
+# Mirrors https://flink.apache.org/downloads/#apache-flink-agents
+# (latest first). Note: 0.1.x only ships JARs for Flink 1.20, while 0.2.x
+# ships JARs for Flink 1.20 / 2.0 / 2.1 / 2.2 — see the download page for
+# the exact compatibility matrix.
+FLINK_AGENTS_SUPPORTED_VERSIONS=("0.2.1" "0.2.0" "0.1.1" "0.1.0")
+FLINK_AGENTS_RECOMMENDED_VERSION="0.2.1"
+
+INSTALL_FLINK="${INSTALL_FLINK:-Ask}"
+ENABLE_PYFLINK="${ENABLE_PYFLINK:-Ask}"
+INSTALL_DIR="${INSTALL_DIR:-$HOME/.local/flink}"
+VENV_DIR="${VENV_DIR:-.flink-agents-env}"
+PYTHON_BIN="${PYTHON_BIN:-}"
+NO_PROMPT="${NO_PROMPT:-0}"
+VERBOSE="${FLINK_AGENTS_VERBOSE:-0}"
+DRY_RUN="${FLINK_AGENTS_DRY_RUN:-0}"
+VERIFY_INSTALL="${FLINK_AGENTS_VERIFY_INSTALL:-0}"
+HELP=0
+PYFLINK_ACTUALLY_ENABLED=0
+
+print_usage() {
+ cat <<EOF
+Apache Flink Agents Installer
+
+Usage:
+ bash install.sh [options]
+ curl -fsSL <url>/install.sh | bash -s -- [options]
+
+Options:
+ --non-interactive Non-interactive mode (accept all defaults)
+ --install-flink Download and install Apache Flink
+ --enable-pyflink Enable PyFlink and install Python packages
+ --verbose Print debug output (set -x)
+ --dry-run Print install plan without making changes
+ --verify Run post-install verification checks
+ --python <path> Path to a Python3 interpreter (overrides PATH lookup)
+ --flink-version <ver> Apache Flink version (e.g. 2.2.0); overrides the
interactive picker
+ --flink-agents-version <ver>
+ Flink Agents version (default:
${FLINK_AGENTS_RECOMMENDED_VERSION}); overrides the picker
+ --help, -h Show this help
+
+Environment variables:
+ FLINK_VERSION Flink version
+ FLINK_AGENTS_VERSION Flink Agents version to install
+ FLINK_SCALA_VERSION Scala version suffix (default: 2.12)
+ FLINK_BASE_URL Mirror base URL for Flink (default:
https://dlcdn.apache.org/flink)
+ FLINK_AGENTS_BASE_URL Mirror base URL for Flink Agents JARs (default:
https://dlcdn.apache.org/flink)
+ FLINK_AGENTS_CHECKSUM_BASE_URL Direct ASF URL for SHA512 sidecars (default:
https://downloads.apache.org/flink)
+ INSTALL_FLINK Ask|Yes|No (default: Ask)
+ ENABLE_PYFLINK Ask|Yes|No (default: Ask)
+ INSTALL_DIR Flink install directory (default:
\$HOME/.local/flink)
+ VENV_DIR Python venv directory (default: .flink-agents-env)
+ PYTHON_BIN Path to Python3 interpreter (default: auto-detect
python3 on PATH)
+ NO_PROMPT 1 to disable all prompts
+ FLINK_AGENTS_VERBOSE 1 to enable verbose output
+ FLINK_AGENTS_DRY_RUN 1 to enable dry-run mode
+ FLINK_AGENTS_VERIFY_INSTALL 1 to enable post-install verification
+
+Examples:
+ bash install.sh --install-flink --enable-pyflink --non-interactive
+ FLINK_VERSION=2.2.0 bash install.sh --verbose
+ bash install.sh --dry-run
+EOF
+}
+
+require_cmd() {
+ command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
+}
+
+# Normalize a filesystem path so downstream string interpolation can rely on
+# a consistent shape. Empty input is passed through (callers handle "user
+# entered nothing"). Otherwise:
+# - "~" prefix expands to $HOME
+# - relative paths are anchored at $PWD
+# - "/./" segments and trailing "/." are folded away
+# - trailing slashes are stripped, except for the root "/"
+# - runs of '/' collapse to a single '/'
+#
+# Implementation note: the folding steps use sed instead of bash
+# `${var//pattern/replacement}`. Bash 3.2 (macOS /bin/bash) does not
+# strip the backslash from an escaped slash in the replacement, so
+# `${p//\/.\//\/}` leaves a literal `\/` in the result (concretely:
+# normalize_path "./" under PWD=/tmp/x produced /tmp/x\ on macOS).
+# sed sidesteps the parameter-expansion quirk entirely.
+normalize_path() {
+ local p="$1"
+ if [[ -z "$p" ]]; then
+ printf ''
+ return 0
+ fi
+ p="${p/#\~/$HOME}"
+ if [[ "$p" != /* ]]; then
+ p="$PWD/$p"
+ fi
+ p="$(printf '%s' "$p" | sed -E 's|/+|/|g; s|/\./|/|g; s|/\.$||; s|/+$||')"
+ [[ -z "$p" ]] && p='/'
+ printf '%s' "$p"
+}
+
+is_valid_tgz() {
+ local archive="$1"
+ [[ -f "$archive" ]] || return 1
+ tar -tzf "$archive" >/dev/null 2>&1
+}
+
+is_promptable() {
+ if [[ "$NO_PROMPT" == "1" ]]; then
+ return 1
+ fi
+ if [[ -r /dev/tty && -w /dev/tty ]]; then
+ return 0
+ fi
+ return 1
+}
+
+choose_install_method_interactive() {
+ local prompt="$1"
+
+ if ! is_promptable; then
+ return 1
+ fi
+
+ if [[ -n "$GUM" ]] && gum_is_tty; then
+ local selection _rc=0
+ selection="$("$GUM" choose \
+ --header "$prompt" \
+ --cursor-prefix "❯ " \
+ "Yes" "No" < /dev/tty)" || _rc=$?
+ (( _rc == 0 )) || die_cancelled
+ [[ "$selection" == "Yes" ]]
+ return
+ fi
+
+ local answer=""
+ printf '%s [y/n]: ' "$prompt" > /dev/tty
+ read -r answer < /dev/tty || die_cancelled
+
+ [[ "$answer" =~ ^[Yy]$ ]]
+}
+
+prompt_flink_version_interactive() {
+ if ! is_promptable; then
+ return 1
+ fi
+
+ local labels=()
+ local v
+ for v in "${FLINK_SUPPORTED_VERSIONS[@]}"; do
+ if [[ "$v" == "$FLINK_RECOMMENDED_VERSION" ]]; then
+ labels+=("$v (recommended)")
+ else
+ labels+=("$v")
+ fi
+ done
+
+ local selection=""
+ if [[ -n "$GUM" ]] && gum_is_tty; then
+ local _rc=0
+ selection="$("$GUM" choose \
+ --header "Select Flink version" \
+ --cursor-prefix "❯ " \
+ "${labels[@]}" < /dev/tty)" || _rc=$?
+ (( _rc == 0 )) || die_cancelled
+ selection="${selection%% *}"
+ else
+ printf 'Select Flink version:\n' > /dev/tty
+ local i=1
+ for v in "${FLINK_SUPPORTED_VERSIONS[@]}"; do
+ local suffix=""
+ [[ "$v" == "$FLINK_RECOMMENDED_VERSION" ]] && suffix="
(recommended)"
+ printf ' %d) %s%s\n' "$i" "$v" "$suffix" > /dev/tty
+ i=$((i+1))
+ done
+ local answer=""
+ printf 'Enter choice [1-%d, default %s]: ' \
+ "${#FLINK_SUPPORTED_VERSIONS[@]}" "$FLINK_RECOMMENDED_VERSION" >
/dev/tty
+ read -r answer < /dev/tty || die_cancelled
+ if [[ "$answer" =~ ^[0-9]+$ ]] \
+ && (( answer >= 1 && answer <= ${#FLINK_SUPPORTED_VERSIONS[@]} ));
then
+ selection="${FLINK_SUPPORTED_VERSIONS[$((answer-1))]}"
+ fi
+ fi
+
+ if [[ -z "$selection" ]]; then
+ selection="$FLINK_RECOMMENDED_VERSION"
+ fi
+ FLINK_VERSION="$selection"
+ return 0
+}
+
+prompt_flink_agents_version_interactive() {
+ if ! is_promptable; then
+ return 1
+ fi
+
+ local labels=()
+ local v
+ for v in "${FLINK_AGENTS_SUPPORTED_VERSIONS[@]}"; do
+ if [[ "$v" == "$FLINK_AGENTS_RECOMMENDED_VERSION" ]]; then
+ labels+=("$v (recommended)")
+ else
+ labels+=("$v")
+ fi
+ done
+
+ local selection=""
+ if [[ -n "$GUM" ]] && gum_is_tty; then
+ local _rc=0
+ selection="$("$GUM" choose \
+ --header "Select Flink Agents version" \
+ --cursor-prefix "❯ " \
+ "${labels[@]}" < /dev/tty)" || _rc=$?
+ (( _rc == 0 )) || die_cancelled
+ selection="${selection%% *}"
+ else
+ printf 'Select Flink Agents version:\n' > /dev/tty
+ local i=1
+ for v in "${FLINK_AGENTS_SUPPORTED_VERSIONS[@]}"; do
+ local suffix=""
+ [[ "$v" == "$FLINK_AGENTS_RECOMMENDED_VERSION" ]] && suffix="
(recommended)"
+ printf ' %d) %s%s\n' "$i" "$v" "$suffix" > /dev/tty
+ i=$((i+1))
+ done
+ local answer=""
+ printf 'Enter choice [1-%d, default %s]: ' \
+ "${#FLINK_AGENTS_SUPPORTED_VERSIONS[@]}"
"$FLINK_AGENTS_RECOMMENDED_VERSION" > /dev/tty
+ read -r answer < /dev/tty || die_cancelled
+ if [[ "$answer" =~ ^[0-9]+$ ]] \
+ && (( answer >= 1 && answer <=
${#FLINK_AGENTS_SUPPORTED_VERSIONS[@]} )); then
+ selection="${FLINK_AGENTS_SUPPORTED_VERSIONS[$((answer-1))]}"
+ fi
+ fi
+
+ if [[ -z "$selection" ]]; then
+ selection="$FLINK_AGENTS_RECOMMENDED_VERSION"
+ fi
+ FLINK_AGENTS_VERSION="$selection"
+ return 0
+}
+
+# Populate FLINK_VERSION from an existing FLINK_HOME. Tries to parse
+# `lib/flink-dist-<ver>.jar` first because it's instant; falls back to
+# Extract the major.minor portion of a Flink version string.
+# 2.2.0 -> 2.2
+# 2.2.0-SNAPSHOT -> 2.2
+# 2.2-SNAPSHOT -> 2.2 (local source builds, no patch number)
+# 2.1-rc1 -> 2.1
+# Returns empty string on no match. Note: replaces the older
+# `${FLINK_VERSION%.*}` trick, which silently produced "2" for "2.2-SNAPSHOT".
+flink_major_minor() {
+ printf '%s' "$1" | sed -E -n 's/^([0-9]+\.[0-9]+).*/\1/p'
+}
+
+# True (rc=0) when the version string carries a pre-release suffix
+# (-SNAPSHOT, -rc1, -dev, -beta-2, ...). PyPI only carries finished
+# release wheels for apache-flink, so we need to know when to fall
+# back from `==exact` to `~=X.Y.0` (compatible release).
+is_snapshot_version() {
+ local v="${1:-}"
+ [[ -n "$v" && "$v" == *-* ]]
+}
+
+# `bin/flink --version`, which is authoritative but spins up a JVM and can
+# take 3-10s on cold start. Returns 0 on success.
+detect_flink_version_from_home() {
+ [[ -n "${FLINK_HOME:-}" && -d "$FLINK_HOME" ]] || return 1
+
+ # A Flink version on the wire is roughly:
+ # <num>.<num>(.<num>)?(-<suffix>)?
+ # Suffix examples: SNAPSHOT, rc1, beta-2, 20251115. Anchor it loose
+ # enough to handle local source builds (flink-dist-2.2-SNAPSHOT.jar).
+ local ver_re='[0-9]+\.[0-9]+(\.[0-9]+)?(-[A-Za-z0-9._]+)?'
+ local version=""
+
+ # Fast path: filename inspection. Avoids JVM startup entirely.
+ local jar
+ for jar in "$FLINK_HOME/lib"/flink-dist-[0-9]*.jar; do
+ [[ -f "$jar" ]] || continue
+ version="$(basename "$jar" | sed -E -n
"s/^flink-dist-(${ver_re})\.jar$/\1/p")"
+ [[ -n "$version" ]] && break
+ done
+
+ # Slow path: ask the Flink CLI. JVM startup means a few seconds of
+ # silence, so signal what's happening.
+ if [[ -z "$version" && -x "$FLINK_HOME/bin/flink" ]]; then
+ ui_info "Detecting Flink version (running '${FLINK_HOME}/bin/flink
--version', this may take a few seconds)..."
+ local out
+ out="$("$FLINK_HOME/bin/flink" --version 2>/dev/null || true)"
+ version="$(printf '%s\n' "$out" | sed -E -n
"s/.*Version:[[:space:]]*(${ver_re}).*/\1/p" | head -n1)"
+ fi
+
+ if [[ -z "$version" ]]; then
+ return 1
+ fi
+
+ FLINK_VERSION="$version"
+ return 0
+}
+
+plan_flink() {
+ case "$INSTALL_FLINK" in
+ Yes|No)
+ ;;
+ Ask)
+ if choose_install_method_interactive "Do you want this script to
download and install Apache Flink?"; then
+ INSTALL_FLINK=Yes
+ else
+ INSTALL_FLINK=No
+ fi
+ ;;
+ *)
+ die "Unsupported INSTALL_FLINK value: ${INSTALL_FLINK}. Use:
Ask|Yes|No"
+ ;;
+ esac
+
+ if [[ "$INSTALL_FLINK" == "No" ]]; then
+ if [[ -n "${FLINK_HOME:-}" ]]; then
+ FLINK_HOME="$(normalize_path "$FLINK_HOME")"
+ fi
+ if is_promptable; then
+ while [[ -z "${FLINK_HOME:-}" || ! -d "${FLINK_HOME}" || ! -d
"${FLINK_HOME}/lib" ]]; do
+ if [[ -n "${FLINK_HOME:-}" ]]; then
+ if [[ ! -d "${FLINK_HOME}" ]]; then
+ ui_warn "Path does not exist: ${FLINK_HOME}"
+ else
+ ui_warn "Not a valid Flink home (missing 'lib'
directory): ${FLINK_HOME}"
+ fi
+ fi
+ FLINK_HOME="$(prompt_path_input "Enter the path to your
existing FLINK_HOME (version >= 1.20)" "/path/to/flink-${FLINK_VERSION}")"
+ done
+ export FLINK_HOME
+ fi
+ [[ -n "${FLINK_HOME:-}" ]] || die "FLINK_HOME is not set."
+ [[ -d "$FLINK_HOME" ]] || die "FLINK_HOME does not exist: $FLINK_HOME"
+ [[ -d "$FLINK_HOME/lib" ]] || die "Invalid FLINK_HOME (missing lib
directory): $FLINK_HOME"
+ ui_success "FLINK_HOME accepted: $FLINK_HOME"
+ if detect_flink_version_from_home; then
+ ui_success "Detected Flink version: $FLINK_VERSION"
+ else
+ ui_warn "Could not auto-detect Flink version from $FLINK_HOME;
assuming ${FLINK_VERSION}."
+ ui_warn "If JAR copy fails, set FLINK_VERSION explicitly (e.g.
FLINK_VERSION=2.1.1 bash install.sh)."
+ fi
+ FLINK_MAJOR_MINOR="$(flink_major_minor "$FLINK_VERSION")"
+ return
+ fi
+
+ if [[ "$FLINK_VERSION_EXPLICIT" -eq 0 ]]; then
+ prompt_flink_version_interactive || true
+ fi
+
+ if [[ "$INSTALL_DIR_EXPLICIT" -eq 0 ]] && is_promptable; then
+ INSTALL_DIR="$(prompt_path_choice_interactive \
+ "Choose Flink install directory" \
+ "$INSTALL_DIR" \
+ "/path/to/flink-install-dir")"
+ fi
+
+ INSTALL_DIR="$(normalize_path "$INSTALL_DIR")"
+ FLINK_HOME="${INSTALL_DIR}/flink-${FLINK_VERSION}"
+ FLINK_MAJOR_MINOR="$(flink_major_minor "$FLINK_VERSION")"
+}
+
+plan_flink_agents() {
+ if [[ "$FLINK_AGENTS_VERSION_EXPLICIT" -eq 0 ]]; then
+ prompt_flink_agents_version_interactive || true
+ fi
+}
+
+# Classify a candidate VENV_DIR path. Echoes one of:
+# new — path doesn't exist; caller should mkdir + python -m venv
+# empty — empty directory; safe to use as venv root
+# venv — already a real Python venv (pyvenv.cfg marker present)
+# nonempty — directory contains foreign files; caller MUST re-prompt
+# file — path is a regular file or invalid; caller MUST re-prompt
+# Exit code: 0 for the safe-to-use cases, 1 for the must-re-prompt cases.
+#
+# We only treat pyvenv.cfg as the venv marker — `bin/activate` alone is
+# not enough since arbitrary projects sometimes ship a file by that name.
+validate_venv_dir() {
+ local p="$1"
+ if [[ -z "$p" ]]; then
+ printf 'file'
+ return 1
+ fi
+ if [[ ! -e "$p" ]]; then
+ printf 'new'
+ return 0
+ fi
+ if [[ ! -d "$p" ]]; then
+ printf 'file'
+ return 1
+ fi
+ if [[ -f "$p/pyvenv.cfg" ]]; then
+ printf 'venv'
+ return 0
+ fi
+ # Directory exists. Empty iff no entries including dotfiles.
+ local entries
+ entries="$(find "$p" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null ||
true)"
+ if [[ -z "$entries" ]]; then
+ printf 'empty'
+ return 0
+ fi
+ printf 'nonempty'
+ return 1
+}
+
+# Drive the "Choose Python venv directory" picker, then loop until the
+# user picks a path that's either non-existent, empty, or an actual
+# Python venv. Sets the global VENV_DIR. Caller must already have
+# ensured we're in an interactive shell (is_promptable).
+prompt_and_validate_venv_dir() {
+ local seed_default="$1"
+ VENV_DIR="$(prompt_path_choice_interactive \
+ "Choose Python venv directory" \
+ "$seed_default" \
+ "/path/to/venv")"
+ VENV_DIR="$(normalize_path "$VENV_DIR")"
+ local kind
+ while true; do
+ kind="$(validate_venv_dir "$VENV_DIR")" || true
+ case "$kind" in
+ new|empty|venv) return 0 ;;
+ nonempty)
+ ui_warn "${VENV_DIR} already exists and is not a Python venv.
Pick a different path, or remove its contents and try again."
+ ;;
+ file|*)
+ ui_warn "${VENV_DIR} is not a directory. Pick a different
path."
+ ;;
+ esac
+ VENV_DIR="$(prompt_path_input \
+ "Enter Python venv directory" \
+ "/path/to/venv")"
+ VENV_DIR="$(normalize_path "$VENV_DIR")"
+ done
+}
+
+plan_pyflink() {
+ case "$ENABLE_PYFLINK" in
+ Yes|No)
+ ;;
+ Ask)
+ if choose_install_method_interactive "Create a Python venv with
PyFlink and flink-agents? (Only needed for Python API users; Java users can
select No)"; then
+ ENABLE_PYFLINK=Yes
+ else
+ ENABLE_PYFLINK=No
+ fi
+ ;;
+ *)
+ die "Unsupported ENABLE_PYFLINK value: ${ENABLE_PYFLINK}. Use:
Ask|Yes|No"
+ ;;
+ esac
+
+ if [[ "$ENABLE_PYFLINK" == "No" ]]; then
+ return
+ fi
+
+ PYFLINK_ACTUALLY_ENABLED=1
+
+ # Validate VENV_DIR before doing anything Python-related — if the
+ # user picked a bad path we want to bail before we make them resolve
+ # a Python interpreter, and certainly before stage 3 downloads Flink.
+ if [[ "$VENV_DIR_EXPLICIT" -eq 0 ]] && is_promptable; then
+ prompt_and_validate_venv_dir "$VENV_DIR"
+ else
+ VENV_DIR="$(normalize_path "$VENV_DIR")"
+ # No prompt available (--non-interactive / NO_PROMPT / explicit
+ # VENV_DIR). Refuse to scribble into a foreign directory; tell
+ # the caller exactly what's wrong instead of silently mixing the
+ # venv into their codebase.
+ local kind
+ kind="$(validate_venv_dir "$VENV_DIR")" || true
+ case "$kind" in
+ new|empty|venv) ;;
+ nonempty)
+ die "VENV_DIR=${VENV_DIR} already exists and is not a Python
venv. Set VENV_DIR to a new or empty path, or to an existing venv."
+ ;;
+ file|*)
+ die "VENV_DIR=${VENV_DIR} is not a directory."
+ ;;
+ esac
+ fi
+
+ resolve_python
+}
+
+# Echo one of: "confirm" | "edit" | "cancel"
+confirm_plan_action_interactive() {
+ if [[ -n "$GUM" ]] && gum_is_tty; then
+ local selection _rc=0
+ selection="$("$GUM" choose \
+ --header "Proceed with installation?" \
+ --cursor-prefix "❯ " \
+ "Proceed" "Edit a setting" "Cancel" < /dev/tty)" || _rc=$?
+ (( _rc == 0 )) || die_cancelled
+ case "$selection" in
+ Proceed) echo confirm ;;
+ "Edit a setting") echo edit ;;
+ *) echo cancel ;;
+ esac
+ return
+ fi
+
+ printf 'Proceed with installation?\n' > /dev/tty
+ printf ' 1) Proceed\n' > /dev/tty
+ printf ' 2) Edit a setting\n' > /dev/tty
+ printf ' 3) Cancel\n' > /dev/tty
+ local answer=""
+ printf 'Enter choice [1-3, default 1]: ' > /dev/tty
+ read -r answer < /dev/tty || die_cancelled
+ case "$answer" in
+ 2) echo edit ;;
+ 3) echo cancel ;;
+ *) echo confirm ;;
+ esac
+}
+
+# Prompt the user to pick a Flink home interactively. Loops until a valid
+# path (exists and has a lib/ subdir) is provided. Mirrors the loop in
+# plan_flink so the two stay in sync.
+edit_prompt_flink_home() {
+ FLINK_HOME=""
+ while [[ -z "${FLINK_HOME:-}" || ! -d "${FLINK_HOME}" || ! -d
"${FLINK_HOME}/lib" ]]; do
+ if [[ -n "${FLINK_HOME:-}" ]]; then
+ if [[ ! -d "${FLINK_HOME}" ]]; then
+ ui_warn "Path does not exist: ${FLINK_HOME}"
+ else
+ ui_warn "Not a valid Flink home (missing 'lib' directory):
${FLINK_HOME}"
+ fi
+ fi
+ FLINK_HOME="$(prompt_path_input "Enter the path to your existing
FLINK_HOME (version >= 1.20)" "/path/to/flink-${FLINK_VERSION}")"
+ done
+ export FLINK_HOME
+}
+
+# Show a menu of currently-applicable plan fields and re-prompt for the
+# selected one. After the edit, recomputes FLINK_HOME / FLINK_MAJOR_MINOR
+# so the next show_install_plan reflects the change.
+# Quote a value for safe re-sourcing by the parent shell via single-quoted
+# assignment. Any embedded single quotes are escaped by closing/reopening
+# the quoted string.
+edit_plan_quote() {
+ local v="$1"
+ printf "'%s'" "${v//\'/\'\\\'\'}"
+}
+
+# Write the subset of plan variables we may have modified to a sourceable
+# state file. Called from inside the edit subshell on a successful action.
+edit_plan_dump_state() {
+ local out="$1"
+ {
+ printf 'INSTALL_FLINK=%s\n' "$(edit_plan_quote
"$INSTALL_FLINK")"
+ printf 'FLINK_VERSION=%s\n' "$(edit_plan_quote
"$FLINK_VERSION")"
+ printf 'INSTALL_DIR=%s\n' "$(edit_plan_quote
"$INSTALL_DIR")"
+ printf 'FLINK_HOME=%s\n' "$(edit_plan_quote
"${FLINK_HOME:-}")"
+ printf 'FLINK_MAJOR_MINOR=%s\n' "$(edit_plan_quote
"${FLINK_MAJOR_MINOR:-}")"
+ printf 'ENABLE_PYFLINK=%s\n' "$(edit_plan_quote
"$ENABLE_PYFLINK")"
+ printf 'PYFLINK_ACTUALLY_ENABLED=%s\n' "$(edit_plan_quote
"$PYFLINK_ACTUALLY_ENABLED")"
+ printf 'VENV_DIR=%s\n' "$(edit_plan_quote
"$VENV_DIR")"
+ printf 'PYTHON_BIN=%s\n' "$(edit_plan_quote
"${PYTHON_BIN:-}")"
+ printf 'FLINK_AGENTS_VERSION=%s\n' "$(edit_plan_quote
"$FLINK_AGENTS_VERSION")"
+ } > "$out"
+}
+
+# Show a menu of currently-applicable plan fields and re-prompt for the
+# selected one. The entire body runs inside a `()` subshell so that an
+# ESC anywhere — top-level menu or any nested sub-prompt — only terminates
+# this menu (subshell exits 130, caller treats it as "back"). Successful
+# edits dump their state to a file that the caller sources back.
+# Ctrl+C is delivered to the whole process group; the parent's INT trap
+# fires die_cancelled and exits the installer before we ever inspect $rc.
+edit_plan_interactive() {
+ local state_file
+ state_file="$(mktempfile)"
+
+ # `set -e` is active, so we MUST catch any non-zero exit from the
+ # subshell with `|| rc=$?` — otherwise the script would terminate the
+ # moment a sub-prompt's ESC bubbles `exit 130` up through the subshell,
+ # and the parent would never get to interpret it as "back".
+ local rc=0
+ (
+ local labels=()
+ local actions=()
+
+ labels+=("Flink Agents version: $FLINK_AGENTS_VERSION")
+ actions+=("flink_agents_version")
+
+ labels+=("Install Flink: $INSTALL_FLINK")
+ actions+=("install_flink")
+
+ if [[ "$INSTALL_FLINK" == "Yes" ]]; then
+ labels+=("Flink version: $FLINK_VERSION")
+ actions+=("flink_version")
+ labels+=("Install directory: $INSTALL_DIR")
+ actions+=("install_dir")
+ else
+ labels+=("FLINK_HOME: ${FLINK_HOME:-<unset>}")
+ actions+=("flink_home")
+ fi
+
+ labels+=("Enable PyFlink: $ENABLE_PYFLINK")
+ actions+=("enable_pyflink")
+
+ if [[ "$ENABLE_PYFLINK" == "Yes" ]]; then
+ labels+=("Venv directory: $VENV_DIR")
+ actions+=("venv_dir")
+ fi
+
+ local picked_index=-1
+ if [[ -n "$GUM" ]] && gum_is_tty; then
+ local _rc=0
+ local selected=""
+ selected="$("$GUM" choose \
+ --header "Edit a setting (↑/↓ navigate · Enter select · Esc
to go back)" \
+ --cursor-prefix "❯ " \
+ "${labels[@]}" < /dev/tty)" || _rc=$?
+ # ESC inside this menu — exit the subshell so the caller treats
+ # it as "back to confirm". The exit code we use here (130) is
+ # what `gum` returns on Esc; we just propagate it.
+ if (( _rc != 0 )); then
+ exit "$_rc"
+ fi
+ local i=0
+ for l in "${labels[@]}"; do
+ if [[ "$l" == "$selected" ]]; then
+ picked_index=$i
+ break
+ fi
+ i=$((i+1))
+ done
+ else
+ printf 'Edit a setting (blank or "b" to go back):\n' > /dev/tty
+ local i=1
+ for l in "${labels[@]}"; do
+ printf ' %d) %s\n' "$i" "$l" > /dev/tty
+ i=$((i+1))
+ done
+ local answer=""
+ printf 'Enter choice [1-%d]: ' "${#labels[@]}" > /dev/tty
+ read -r answer < /dev/tty || die_cancelled
+ case "$answer" in
+ ""|b|B|back|0) exit 130 ;;
+ esac
+ if [[ "$answer" =~ ^[0-9]+$ ]] && (( answer >= 1 && answer <=
${#labels[@]} )); then
+ picked_index=$((answer - 1))
+ fi
+ fi
+
+ if (( picked_index < 0 )); then
+ # No valid selection — same as back.
+ exit 130
+ fi
+
+ local action="${actions[$picked_index]}"
+ case "$action" in
+ flink_agents_version)
+ prompt_flink_agents_version_interactive || true
+ ;;
+ install_flink)
+ if choose_install_method_interactive "Install Flink?"; then
+ if [[ "$INSTALL_FLINK" != "Yes" ]]; then
+ INSTALL_FLINK=Yes
+ # Coming from No → Yes: caller had no version/dir, so
+ # walk them through both immediately instead of
+ # forcing two more menu trips.
+ prompt_flink_version_interactive || true
+ INSTALL_DIR="$(prompt_path_choice_interactive \
+ "Choose Flink install directory" \
+ "$INSTALL_DIR" \
+ "/path/to/flink-install-dir")"
+ INSTALL_DIR="$(normalize_path "$INSTALL_DIR")"
+ fi
+ FLINK_HOME="${INSTALL_DIR}/flink-${FLINK_VERSION}"
+ else
+ INSTALL_FLINK=No
+ edit_prompt_flink_home
+ detect_flink_version_from_home || ui_warn "Could not
auto-detect Flink version; keeping ${FLINK_VERSION}"
+ fi
+ FLINK_MAJOR_MINOR="$(flink_major_minor "$FLINK_VERSION")"
+ ;;
+ flink_version)
+ prompt_flink_version_interactive || true
+ FLINK_HOME="${INSTALL_DIR}/flink-${FLINK_VERSION}"
+ FLINK_MAJOR_MINOR="$(flink_major_minor "$FLINK_VERSION")"
+ ;;
+ install_dir)
+ INSTALL_DIR="$(prompt_path_choice_interactive \
+ "Choose Flink install directory" \
+ "$INSTALL_DIR" \
+ "/path/to/flink-install-dir")"
+ INSTALL_DIR="$(normalize_path "$INSTALL_DIR")"
+ FLINK_HOME="${INSTALL_DIR}/flink-${FLINK_VERSION}"
+ ;;
+ flink_home)
+ edit_prompt_flink_home
+ ;;
+ enable_pyflink)
+ if choose_install_method_interactive "Enable PyFlink?"; then
+ if [[ "$ENABLE_PYFLINK" != "Yes" ]]; then
+ ENABLE_PYFLINK=Yes
+ PYFLINK_ACTUALLY_ENABLED=1
+ resolve_python
+ prompt_and_validate_venv_dir "$VENV_DIR"
+ fi
+ else
+ ENABLE_PYFLINK=No
+ fi
+ ;;
+ venv_dir)
+ prompt_and_validate_venv_dir "$VENV_DIR"
+ ;;
+ esac
+
+ edit_plan_dump_state "$state_file"
+ ) || rc=$?
+
+ if (( rc != 0 )); then
+ # Any non-zero exit from the subshell means we never reached the
+ # state dump — treat it as "back to confirm". (Ctrl+C would have
+ # killed the parent before this point via the INT trap.)
+ return 0
+ fi
+
+ # shellcheck disable=SC1090
+ source "$state_file"
+}
+
+confirm_install_plan() {
+ if [[ "$NO_PROMPT" == "1" ]] || ! is_promptable; then
+ return 0
+ fi
+ while true; do
+ local action
+ # Capture stdout AND honor the child's exit code. The child runs in a
+ # command-substitution subshell, which does NOT inherit the parent's
+ # `trap die_cancelled INT`. When Ctrl+C lands inside `gum choose`,
+ # the subshell exits 130 but its stdout is empty — without this `||`,
+ # the case-statement falls through to the default arm and we'd loop
+ # forever re-prompting instead of exiting.
+ action="$(confirm_plan_action_interactive)" || exit 130
+ case "$action" in
+ confirm) return 0 ;;
+ edit)
+ edit_plan_interactive
+ # Re-display the updated plan so the user sees their change
+ # in context before re-prompting.
+ show_install_plan
+ ;;
+ cancel)
+ ui_info "Installation cancelled by user."
+ exit 0
+ ;;
+ esac
+ done
+}
+
+install_flink_if_needed() {
+ if [[ "$INSTALL_FLINK" == "No" ]]; then
+ ui_info "Skipping Flink download/install (using existing FLINK_HOME)."
+ ui_success "FLINK_HOME resolved: $FLINK_HOME"
+ return
+ fi
+
+ local
ARCHIVE_NAME="flink-${FLINK_VERSION}-bin-scala_${FLINK_SCALA_VERSION}.tgz"
+ local
ARCHIVE_URL="${FLINK_BASE_URL}/flink-${FLINK_VERSION}/${ARCHIVE_NAME}"
+
+ detect_downloader
+ require_cmd tar
+
+ if ! mkdir -p "$INSTALL_DIR"; then
+ die "Failed to create INSTALL_DIR=$INSTALL_DIR. Please run with proper
permissions or set INSTALL_DIR to a writable path."
+ fi
+ if [[ -f "${INSTALL_DIR}/${ARCHIVE_NAME}" ]] && ! is_valid_tgz
"${INSTALL_DIR}/${ARCHIVE_NAME}"; then
+ ui_warn "Existing archive is corrupted; re-downloading:
${INSTALL_DIR}/${ARCHIVE_NAME}"
+ rm -f "${INSTALL_DIR}/${ARCHIVE_NAME}"
+ fi
+
+ if [[ ! -f "${INSTALL_DIR}/${ARCHIVE_NAME}" ]]; then
+ ui_info "Downloading ${ARCHIVE_URL}"
+ download_file "$ARCHIVE_URL" "${INSTALL_DIR}/${ARCHIVE_NAME}"
+ else
+ ui_info "Reusing existing archive: ${INSTALL_DIR}/${ARCHIVE_NAME}"
+ fi
+
+ if ! is_valid_tgz "${INSTALL_DIR}/${ARCHIVE_NAME}"; then
+ die "Downloaded archive is invalid or truncated:
${INSTALL_DIR}/${ARCHIVE_NAME}"
+ fi
+
+ if [[ -d "${INSTALL_DIR}/flink-${FLINK_VERSION}" ]] && [[ ! -d
"${INSTALL_DIR}/flink-${FLINK_VERSION}/lib" ]]; then
+ ui_warn "Existing Flink home is incomplete; re-extracting:
${INSTALL_DIR}/flink-${FLINK_VERSION}"
+ rm -rf "${INSTALL_DIR}/flink-${FLINK_VERSION}"
+ fi
+
+ if [[ ! -d "${INSTALL_DIR}/flink-${FLINK_VERSION}" ]]; then
+ ui_info "Extracting Flink to ${INSTALL_DIR}"
+ tar -xzf "${INSTALL_DIR}/${ARCHIVE_NAME}" -C "$INSTALL_DIR"
+ else
+ ui_info "Reusing existing Flink home:
${INSTALL_DIR}/flink-${FLINK_VERSION}"
+ fi
+
+ export FLINK_HOME
+ ui_success "FLINK_HOME resolved: $FLINK_HOME"
+}
+
+prompt_path_input() {
+ local header="$1"
+ # Placeholder kept for backward compatibility with the previous signature;
+ # readline does not surface placeholders the way `gum input` did, so we
+ # weave the hint into the prompt label instead.
+ local placeholder="${2:-}"
+ local input=""
+ printf '%s\n' "$header" > /dev/tty
+ if [[ -n "$placeholder" ]]; then
+ printf ' (example: %s)\n' "$placeholder" > /dev/tty
+ fi
+ IFS= read -e -r -p " path> " input < /dev/tty || die_cancelled
+ printf '%s' "$(normalize_path "$input")"
+}
+
+prompt_path_choice_interactive() {
+ local header="$1"
+ local default_path="$2"
+ local placeholder="$3"
+
+ if ! is_promptable; then
+ printf '%s' "$default_path"
+ return 0
+ fi
+
+ local default_label="Default: ${default_path}"
+ local custom_label="Custom..."
+ local selection=""
+
+ if [[ -n "$GUM" ]] && gum_is_tty; then
+ local _rc=0
+ selection="$("$GUM" choose \
+ --header "$header" \
+ --cursor-prefix "❯ " \
+ "$default_label" "$custom_label" < /dev/tty)" || _rc=$?
+ (( _rc == 0 )) || die_cancelled
+ else
+ printf '%s\n' "$header" > /dev/tty
+ printf ' 1) %s\n' "$default_label" > /dev/tty
+ printf ' 2) %s\n' "$custom_label" > /dev/tty
+ local answer=""
+ printf 'Enter choice [1-2, default 1]: ' > /dev/tty
+ read -r answer < /dev/tty || die_cancelled
+ case "$answer" in
+ 2) selection="$custom_label" ;;
+ *) selection="$default_label" ;;
+ esac
+ fi
+
+ if [[ "$selection" != "$custom_label" ]]; then
+ printf '%s' "$(normalize_path "$default_path")"
+ return 0
+ fi
+
+ local input=""
+ printf '%s\n' "$header" > /dev/tty
+ IFS= read -e -r -p " path> " input < /dev/tty || die_cancelled
+
+ if [[ -z "$input" ]]; then
+ ui_warn "Empty path; falling back to default: $default_path"
+ printf '%s' "$(normalize_path "$default_path")"
+ return 0
+ fi
+
+ printf '%s' "$(normalize_path "$input")"
+}
+
+copy_pyflink_jar() {
+ local pyflink_jar="$FLINK_HOME/opt/flink-python-${FLINK_VERSION}.jar"
+ [[ -f "$pyflink_jar" ]] || die "Missing required PyFlink jar: $pyflink_jar"
+
+ ui_info "Copying PyFlink jar into Flink lib"
+ cp "$pyflink_jar" "$FLINK_HOME/lib/"
+}
+
+create_venv() {
+ local venv_err
+ venv_err="$(mktempfile)"
+ if "$PYTHON_BIN" -m venv "$VENV_DIR" 2>"$venv_err"; then
+ return 0
+ fi
+
+ ui_error "Failed to create virtual environment at $VENV_DIR"
+ if [[ -s "$venv_err" ]]; then
+ sed 's/^/ /' "$venv_err" >&2 || true
+ fi
+ die "Virtual environment creation failed"
+}
+
+# Run `python -m pip install <args>` with reduced terminal noise.
+# Strategy depends on environment:
+# VERBOSE=1 : full output, pass-through
+# gum available + tty : `gum spin --show-error` (spinner; pip output
+# is hidden unless pip exits non-zero)
+# plain tty (no gum) : single rolling status line via \r\033[K;
+# full output goes to a temp log and is shown
+# only on failure
+# non-tty (CI / piped) : pip --quiet (errors still surface)
+pip_install_quiet() {
+ local pkgs=("$@")
+
+ if [[ "$VERBOSE" == "1" ]]; then
+ python -m pip install "${pkgs[@]}"
+ return
+ fi
+
+ if [[ -n "$GUM" ]] && gum_is_tty; then
+ "$GUM" spin --show-error \
+ --title "pip install ${pkgs[*]}" \
+ -- python -m pip install "${pkgs[@]}"
+ return
+ fi
+
+ if [[ ! -t 1 ]]; then
+ python -m pip install -q "${pkgs[@]}"
+ return
+ fi
+
+ local log
+ log="$(mktempfile)"
+ local cols max
+ cols="$(tput cols 2>/dev/null || echo 80)"
+ local prefix=" · "
+ max=$((cols - ${#prefix} - 4))
+ (( max < 20 )) && max=20
+
+ local rc=0
+ {
+ python -m pip install "${pkgs[@]}" 2>&1 \
+ | while IFS= read -r line; do
+ printf '%s\n' "$line" >> "$log"
+ printf '\r\033[K%s%s' "$prefix" "${line:0:$max}"
+ done
+ } || rc=$?
+
+ # Clear the rolling status line.
+ printf '\r\033[K'
+
+ if (( rc != 0 )); then
+ ui_error "pip install failed; full output:"
+ sed 's/^/ /' "$log" >&2
+ return $rc
+ fi
+}
+
+setup_python_env() {
+ if [[ ! -d "$VENV_DIR" ]]; then
+ ui_info "Creating virtual environment: $VENV_DIR"
+ create_venv
+ else
+ ui_info "Reusing existing virtual environment: $VENV_DIR"
+ fi
+
+ source "$VENV_DIR/bin/activate"
+
+ export PIP_PROGRESS_BAR=off
+ export PIP_NO_COLOR=1
+ export PIP_NO_INPUT=1
+
+ # PyPI only ships finished release wheels for apache-flink, so a
+ # source-built FLINK_VERSION like "2.1-SNAPSHOT" / "2.0.0-rc1" has
+ # no matching distribution. Fall back to the compatible-release
+ # operator (~= X.Y.0 ↔ >=X.Y.0,<X.(Y+1)) for these cases — the
+ # user's Java JARs are still the source build, but PyFlink will
+ # come from the nearest released minor on PyPI.
+ local apache_flink_spec="apache-flink==${FLINK_VERSION}"
+ if is_snapshot_version "$FLINK_VERSION"; then
+ apache_flink_spec="apache-flink~=${FLINK_MAJOR_MINOR}.0"
+ ui_warn "FLINK_VERSION=${FLINK_VERSION} is a pre-release build;
installing ${apache_flink_spec} from PyPI instead."
+ fi
+
+ ui_info "Installing Python packages (may take a few minutes)..."
+ pip_install_quiet \
+ "flink-agents==${FLINK_AGENTS_VERSION}" \
+ "$apache_flink_spec"
+}
+
+# Compute the relative path of the flink-agents JAR under the ASF mirror,
+# e.g. flink-agents-0.2.1/flink-agents-dist-flink-2.2-0.2.1.jar
+flink_agents_jar_relpath() {
+ printf '%s'
"flink-agents-${FLINK_AGENTS_VERSION}/flink-agents-dist-flink-${FLINK_MAJOR_MINOR}-${FLINK_AGENTS_VERSION}.jar"
+}
+
+# Compare a downloaded artifact against its .sha512 sidecar. The sidecar
+# format from ASF is "<hex> <filename>" (single line). Best-effort: a
+# missing sidecar or missing sha512 tool warns but does not fail (mirrors
+# may not always carry the checksum on time).
+verify_flink_agents_jar_sha512() {
+ local jar="$1"
+ local sha_url="$2"
+ local sha_file
+ sha_file="$(mktempfile)"
+
+ if ! download_file "$sha_url" "$sha_file" >/dev/null 2>&1; then
+ ui_warn "SHA512 sidecar unavailable (${sha_url}); skipping
verification"
+ return 0
+ fi
+
+ local expected actual
+ expected="$(awk 'NR==1 {print $1}' "$sha_file" 2>/dev/null || true)"
+ if [[ -z "$expected" ]]; then
+ ui_warn "SHA512 sidecar is empty; skipping verification"
+ return 0
+ fi
+ # SHA-512 hex is exactly 128 lowercase/upper hex chars. If the sidecar
+ # didn't look like a real ASF .sha512 file, warn instead of failing.
+ if [[ ! "$expected" =~ ^[A-Fa-f0-9]{128}$ ]]; then
+ ui_warn "SHA512 sidecar at ${sha_url} is not a valid hash; skipping
verification"
+ return 0
+ fi
+
+ if command -v sha512sum >/dev/null 2>&1; then
+ actual="$(sha512sum "$jar" | awk '{print $1}')"
+ elif command -v shasum >/dev/null 2>&1; then
+ actual="$(shasum -a 512 "$jar" | awk '{print $1}')"
+ else
+ ui_warn "Neither sha512sum nor shasum available; skipping SHA512
verification"
+ return 0
+ fi
+
+ # Use `tr` for case-folding instead of `${var,,}` — the latter is
+ # bash 4+ only, and macOS still ships /bin/bash 3.2.
+ local expected_lc actual_lc
+ expected_lc="$(printf '%s' "$expected" | tr '[:upper:]' '[:lower:]')"
+ actual_lc="$(printf '%s' "$actual" | tr '[:upper:]' '[:lower:]')"
+ if [[ "$expected_lc" != "$actual_lc" ]]; then
+ die "SHA512 mismatch for $(basename "$jar") (expected
${expected:0:16}…, got ${actual:0:16}…)"
+ fi
+ ui_success "SHA512 verified"
+}
+
+# Download flink-agents-dist-flink-<flink_major.minor>-<agents_ver>.jar
+# directly from the ASF mirror into FLINK_HOME/lib. This replaces the older
+# wheel-extraction path, so Java-only users no longer need Python at all.
+install_flink_agents_jar() {
+ [[ -n "${FLINK_HOME:-}" ]] || die "FLINK_HOME is not set"
+ [[ -d "$FLINK_HOME/lib" ]] || die "Invalid FLINK_HOME (missing lib):
$FLINK_HOME"
+
+ local relpath jar_name target url sha_url
+ relpath="$(flink_agents_jar_relpath)"
+ jar_name="$(basename "$relpath")"
+ target="$FLINK_HOME/lib/$jar_name"
+ url="${FLINK_AGENTS_BASE_URL}/${relpath}"
+ sha_url="${FLINK_AGENTS_CHECKSUM_BASE_URL}/${relpath}.sha512"
+
+ detect_downloader
+
+ if [[ -f "$target" ]]; then
+ ui_info "Reusing existing JAR: ${target}"
+ verify_flink_agents_jar_sha512 "$target" "$sha_url" || true
+ return 0
+ fi
+
+ ui_info "Downloading ${url}"
+ if ! download_file "$url" "$target"; then
+ rm -f "$target" 2>/dev/null || true
+ die "Failed to download flink-agents JAR from ${url}"
+ fi
+
+ if [[ ! -s "$target" ]]; then
+ rm -f "$target" 2>/dev/null || true
+ die "Downloaded an empty file: ${target}"
+ fi
+
+ verify_flink_agents_jar_sha512 "$target" "$sha_url"
+ ui_success "Installed: ${jar_name} → ${FLINK_HOME}/lib"
+}
+
+check_java() {
+ if ! command -v java &>/dev/null; then
+ ui_warn "Java not found on PATH"
+ if [[ "$OS" == "macos" ]]; then
+ ui_info "Install Java: brew install openjdk@17"
+ elif [[ "$OS" == "linux" ]]; then
+ ui_info "Install Java: sudo apt install openjdk-17-jdk
(Debian/Ubuntu)"
+ ui_info " sudo yum install java-17-openjdk-devel
(RHEL/CentOS)"
+ fi
+ ui_info "Flink requires Java 11+ to run"
+ return 1
+ fi
+
+ local java_version_output
+ java_version_output="$(java -version 2>&1 | head -n1)"
+ local java_major=""
+ java_major="$(echo "$java_version_output" | sed -E -n 's/.*version
"?([0-9]+).*/\1/p')"
+
+ if [[ -z "$java_major" ]]; then
+ ui_warn "Could not parse Java version from: $java_version_output"
+ return 1
+ fi
+
+ if [[ "$java_major" -lt 11 ]]; then
+ ui_error "Java $java_major detected, but Flink requires Java 11+"
+ ui_info "Please upgrade your Java installation"
+ return 1
+ fi
+
+ ui_success "Java $java_major found"
+
+ if [[ -z "${JAVA_HOME:-}" ]]; then
+ ui_info "JAVA_HOME is not set (Flink will try to detect it
automatically)"
+ fi
+ return 0
+}
+
+validate_python_bin() {
+ local bin="$1"
+ [[ -n "$bin" ]] || return 1
+ command -v "$bin" >/dev/null 2>&1 || return 1
+
+ local py_version_output
+ py_version_output="$("$bin" -c 'import sys;
print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null ||
true)"
+ [[ -n "$py_version_output" ]] || return 1
+
+ local py_major py_minor
+ py_major="${py_version_output%%.*}"
+ py_minor="${py_version_output##*.}"
+
+ if [[ "$py_major" -ne 3 ]] || [[ "$py_minor" -lt 10 ]] || [[ "$py_minor"
-ge 12 ]]; then
+ return 1
+ fi
+ return 0
+}
+
+resolve_python() {
+ if [[ -n "$PYTHON_BIN" ]]; then
+ if validate_python_bin "$PYTHON_BIN"; then
+ ui_success "Using Python: $PYTHON_BIN"
+ return 0
+ fi
+ die "PYTHON_BIN is invalid or unsupported (need >=3.10 and <3.12):
$PYTHON_BIN"
+ fi
+
+ if validate_python_bin python3; then
+ PYTHON_BIN="python3"
+ local v
+ v="$(python3 -c 'import sys;
print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null)"
+ ui_success "Python $v found on PATH"
+ return 0
+ fi
+
+ if command -v python3 >/dev/null 2>&1; then
+ ui_warn "python3 on PATH is incompatible (Flink Agents requires Python
>=3.10 and <3.12)"
+ else
+ ui_warn "python3 not found on PATH"
+ fi
+
+ if ! is_promptable; then
+ die "No compatible Python found. Set PYTHON_BIN or pass --python
<path>."
+ fi
+
+ local input
+ input="$(prompt_path_input "Enter path to a Python interpreter"
"/path/to/python3")"
+ if [[ -z "$input" ]]; then
+ die "No Python interpreter provided."
+ fi
+ if ! validate_python_bin "$input"; then
+ die "Provided Python is invalid or unsupported (need >=3.10 and
<3.12): $input"
+ fi
+ PYTHON_BIN="$input"
+ ui_success "Using Python: $PYTHON_BIN"
+ return 0
+}
+
+show_install_plan() {
+ ui_section "Environment (read-only)"
+ ui_kv "OS" "$OS"
+ local java_summary="not found"
+ if command -v java >/dev/null 2>&1; then
+ local jv
+ jv="$(java -version 2>&1 | head -n1 | sed -E 's/^[^ ]+ version
"?([^"]+)"?.*/\1/')"
+ java_summary="${jv:-detected}"
+ fi
+ ui_kv "Java" "$java_summary"
+ if [[ -n "${JAVA_HOME:-}" ]]; then
+ ui_kv "JAVA_HOME" "$JAVA_HOME"
+ else
+ ui_kv "JAVA_HOME" "<not set, Flink will auto-detect>"
+ fi
+ local python_summary="<none>"
+ if [[ -n "$PYTHON_BIN" ]] && command -v "$PYTHON_BIN" >/dev/null 2>&1; then
+ local pv
+ pv="$("$PYTHON_BIN" -c 'import sys;
print(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")'
2>/dev/null || true)"
+ python_summary="${PYTHON_BIN}${pv:+ ($pv)}"
+ elif command -v python3 >/dev/null 2>&1; then
+ local pv
+ pv="$(python3 -c 'import sys;
print(f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}")'
2>/dev/null || true)"
+ python_summary="python3${pv:+ ($pv)}"
+ fi
+ ui_kv "Python" "$python_summary"
+
+ ui_section "Installation plan"
+ ui_kv "Flink Agents version" "$FLINK_AGENTS_VERSION"
+ ui_kv "Install Flink" "$INSTALL_FLINK"
+ if [[ "$INSTALL_FLINK" == "Yes" ]]; then
+ ui_kv "Flink version" "$FLINK_VERSION"
+ ui_kv "Install directory" "$INSTALL_DIR"
+ else
+ if [[ -n "${FLINK_HOME:-}" ]]; then
+ ui_kv "FLINK_HOME" "$FLINK_HOME (v$FLINK_VERSION)"
+ fi
+ fi
+ ui_kv "Enable PyFlink" "$ENABLE_PYFLINK"
+ if [[ "$ENABLE_PYFLINK" == "Yes" ]] || [[ "$PYFLINK_ACTUALLY_ENABLED" -eq
1 ]]; then
+ ui_kv "Venv directory" "$VENV_DIR"
+ fi
+ if [[ "$DRY_RUN" == "1" ]]; then
+ ui_kv "Dry run" "yes"
+ fi
+ if [[ "$VERIFY_INSTALL" == "1" ]]; then
+ ui_kv "Verify" "yes"
+ fi
+}
+
+verify_installation() {
+ if [[ "${VERIFY_INSTALL}" != "1" ]]; then
+ return 0
+ fi
+
+ ui_stage "Verifying installation"
+
+ if [[ -x "$FLINK_HOME/bin/flink" ]]; then
+ local flink_ver
+ flink_ver="$("$FLINK_HOME/bin/flink" --version 2>/dev/null || true)"
+ if [[ -n "$flink_ver" ]]; then
+ ui_success "Flink binary: $flink_ver"
+ else
+ ui_warn "Flink binary exists but --version failed"
+ fi
+ else
+ ui_warn "Flink binary not found at $FLINK_HOME/bin/flink"
+ fi
+
+ local
expected_jar="$FLINK_HOME/lib/flink-agents-dist-flink-${FLINK_MAJOR_MINOR}-${FLINK_AGENTS_VERSION}.jar"
+ if [[ -f "$expected_jar" ]]; then
+ ui_success "flink-agents JAR found: $(basename "$expected_jar")"
+ else
+ ui_error "Expected flink-agents JAR missing: $expected_jar"
+ return 1
+ fi
+
+ if [[ "$PYFLINK_ACTUALLY_ENABLED" -eq 1 ]]; then
+ if python -c "import flink_agents; print('flink-agents',
flink_agents.__version__)" 2>/dev/null; then
+ ui_success "flink-agents Python package verified"
+ else
+ ui_error "flink-agents Python package import failed"
+ return 1
+ fi
+
+ if [[ -f "$FLINK_HOME/lib/flink-python-${FLINK_VERSION}.jar" ]]; then
+ ui_success "flink-python JAR found in FLINK_HOME/lib"
+ else
+ ui_warn "flink-python-${FLINK_VERSION}.jar not found in
$FLINK_HOME/lib"
+ fi
+ fi
+
+ ui_success "Verification complete"
+}
+
+show_footer_links() {
+ local
docs_url="https://nightlies.apache.org/flink/flink-agents-docs-latest/"
+ local issues_url="https://github.com/apache/flink-agents/issues"
+ echo ""
+ ui_info "Documentation: ${docs_url}"
+ ui_info "Report issues: ${issues_url}"
+}
+
+parse_args() {
+ while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --non-interactive)
+ NO_PROMPT=1
+ shift
+ ;;
+ --install-flink)
+ INSTALL_FLINK=Yes
+ shift
+ ;;
+ --enable-pyflink|--enable-pyFlink)
+ ENABLE_PYFLINK=Yes
+ shift
+ ;;
+ --verbose)
+ VERBOSE=1
+ shift
+ ;;
+ --dry-run)
+ DRY_RUN=1
+ shift
+ ;;
+ --verify)
+ VERIFY_INSTALL=1
+ shift
+ ;;
+ --python)
+ if [[ $# -lt 2 ]]; then
+ die "--python requires a path argument"
+ fi
+ PYTHON_BIN="$2"
+ shift 2
+ ;;
+ --python=*)
+ PYTHON_BIN="${1#*=}"
+ shift
+ ;;
+ --flink-agents-version)
+ if [[ $# -lt 2 ]]; then
+ die "--flink-agents-version requires a version argument"
+ fi
+ FLINK_AGENTS_VERSION="$2"
+ FLINK_AGENTS_VERSION_EXPLICIT=1
+ shift 2
+ ;;
+ --flink-agents-version=*)
+ FLINK_AGENTS_VERSION="${1#*=}"
+ FLINK_AGENTS_VERSION_EXPLICIT=1
+ shift
+ ;;
+ --flink-version)
+ if [[ $# -lt 2 ]]; then
+ die "--flink-version requires a version argument"
+ fi
+ FLINK_VERSION="$2"
+ FLINK_VERSION_EXPLICIT=1
+ shift 2
+ ;;
+ --flink-version=*)
+ FLINK_VERSION="${1#*=}"
+ FLINK_VERSION_EXPLICIT=1
+ shift
+ ;;
+ --help|-h)
+ HELP=1
+ shift
+ ;;
+ *)
+ ui_warn "Unknown option: $1"
+ shift
+ ;;
+ esac
+ done
+}
+
+configure_verbose() {
+ if [[ "$VERBOSE" != "1" ]]; then
+ return 0
+ fi
+ if [[ "$NPM_LOGLEVEL" == "error" ]]; then
+ NPM_LOGLEVEL="notice"
+ fi
+ NPM_SILENT_FLAG=""
+ set -x
+}
+
+main() {
+ if [[ "$HELP" == "1" ]]; then
+ print_usage
+ return 0
+ fi
+
+ bootstrap_gum_temp || true
+ print_installer_banner
+ print_gum_status
+ detect_os_or_die
+ check_java || die "Java environment check failed. Please install Java 11
or newer."
+
+ ui_stage "Planning Flink installation"
+ plan_flink
+ plan_flink_agents
+
+ ui_stage "Planning Python environment"
+ plan_pyflink
+
+ show_install_plan
+
+ if [[ "$DRY_RUN" == "1" ]]; then
+ ui_success "Dry run complete (no changes made)"
+ return 0
+ fi
+
+ confirm_install_plan
+
+ ui_stage "Installing Apache Flink"
+ install_flink_if_needed
+
+ ui_stage "Installing Flink Agents"
+ install_flink_agents_jar
+ if [[ "$ENABLE_PYFLINK" == "Yes" ]]; then
+ copy_pyflink_jar
+ setup_python_env
+ else
+ ui_info "Skipping Python venv (ENABLE_PYFLINK=No)."
+ fi
+
+ ui_stage "Finalizing"
+
+ verify_installation
+
+ echo ""
+ ui_celebrate "Apache Flink Agents installation finished!"
+ echo ""
+ ui_section "Next steps"
+ ui_success "1) Point FLINK_HOME at this install (Flink CLI and clients
read it):"
+ ui_info " export FLINK_HOME=${FLINK_HOME}"
+ if [[ "$PYFLINK_ACTUALLY_ENABLED" -eq 1 ]]; then
+ ui_success "2) Activate the Python venv (PyFlink + flink-agents are
installed there):"
+ ui_info " source ${VENV_DIR}/bin/activate"
+ ui_success "3) To make both permanent, append the two lines above to
your shell rc"
+ ui_info " (~/.zshrc or ~/.bashrc)."
+ else
+ ui_success "2) To make it permanent, append the line above to your
shell rc"
+ ui_info " (~/.zshrc or ~/.bashrc)."
+ fi
+
+ show_footer_links
+}
+
+if [[ "${FLINK_AGENTS_INSTALL_SH_NO_RUN:-0}" != "1" ]]; then
+ parse_args "$@"
+ configure_verbose
+ main
+fi
+
diff --git a/tools/test/.gitignore b/tools/test/.gitignore
new file mode 100644
index 00000000..74b45037
--- /dev/null
+++ b/tools/test/.gitignore
@@ -0,0 +1 @@
+.bats-cache/
diff --git a/tools/test/helpers/load.bash b/tools/test/helpers/load.bash
new file mode 100644
index 00000000..308d450d
--- /dev/null
+++ b/tools/test/helpers/load.bash
@@ -0,0 +1,50 @@
+# Helpers used by every bats file. Loaded via `load 'helpers/load'`.
+
+# Sources install.sh with the no-run hook so main() is skipped.
+load_install_sh() {
+ export FLINK_AGENTS_INSTALL_SH_NO_RUN=1
+ # shellcheck disable=SC1090
+ source "${BATS_TEST_DIRNAME}/../../install.sh"
+ # Note: install.sh sets `set -euo pipefail`, which matches the
+ # strict mode bats itself enables. Leave it on so bats's ERR trap
+ # can detect failed assertions. Use `run <cmd>` (which captures
+ # $status in a subshell) for tests that exercise failure paths.
+}
+
+# Resets every module-level variable install.sh defines, so tests don't
+# leak state into one another.
+reset_install_sh_state() {
+ TMPFILES=()
+ INSTALL_STAGE_CURRENT=0
+ GUM=""
+ GUM_STATUS="skipped"
+ GUM_REASON=""
+ DOWNLOADER=""
+ HELP=0
+ DRY_RUN=0
+ NO_PROMPT=0
+ VERBOSE=0
+ VERIFY_INSTALL=0
+ INSTALL_FLINK="Ask"
+ ENABLE_PYFLINK="Ask"
+ PYTHON_BIN=""
+ PYFLINK_ACTUALLY_ENABLED=0
+ FLINK_VERSION="2.2.0"
+ FLINK_AGENTS_VERSION="0.2.1"
+ FLINK_SCALA_VERSION="2.12"
+ FLINK_BASE_URL="https://dlcdn.apache.org/flink"
+ FLINK_SUPPORTED_VERSIONS=("2.2.0" "2.1.1" "2.0.1" "1.20.3")
+ FLINK_RECOMMENDED_VERSION="2.2.0"
+ INSTALL_DIR="$HOME/.local/flink"
+ VENV_DIR=".flink-agents-env"
+ GUM_VERSION="0.17.0"
+ # Default the bootstrap cache to a per-test directory so we never touch
+ # the developer's real $HOME/.cache when running locally.
+ GUM_CACHE_ROOT="${BATS_TEST_TMPDIR:-/tmp}/gum-cache"
+ FLINK_VERSION_EXPLICIT=0
+ FLINK_AGENTS_VERSION_EXPLICIT=0
+ INSTALL_DIR_EXPLICIT=0
+ VENV_DIR_EXPLICIT=0
+ FLINK_AGENTS_SUPPORTED_VERSIONS=("0.2.1" "0.2.0" "0.1.1" "0.1.0")
+ FLINK_AGENTS_RECOMMENDED_VERSION="0.2.1"
+}
diff --git a/tools/test/helpers/shim.bash b/tools/test/helpers/shim.bash
new file mode 100644
index 00000000..1d60a5b1
--- /dev/null
+++ b/tools/test/helpers/shim.bash
@@ -0,0 +1,88 @@
+# PATH-shim helpers for integration tests. Loaded via `load 'helpers/shim'`.
+#
+# After `shim_setup` runs, $BATS_TEST_TMPDIR/bin is prepended to PATH and
+# every shimmed binary appends one tab-separated line per invocation to
+# $BATS_TEST_TMPDIR/calls/<name>.log.
+#
+# `shim_bin_missing` additionally registers a name in SHIM_MISSING_NAMES;
+# the `command` function override below intercepts `command -v <name>` for
+# those names and returns 1, so install.sh's `command -v curl &>/dev/null`
+# checks see them as unavailable.
+
+shim_setup() {
+ SHIM_DIR="$BATS_TEST_TMPDIR/bin"
+ SHIM_CALLS="$BATS_TEST_TMPDIR/calls"
+ SHIM_MISSING_NAMES=()
+ mkdir -p "$SHIM_DIR" "$SHIM_CALLS"
+ export PATH="$SHIM_DIR:$PATH"
+}
+
+# Override `command` as a shell function. When SHIM_MISSING_NAMES is empty
+# (i.e. shim_setup hasn't been called or nothing has been marked missing),
+# this is fully transparent — every call falls through to `builtin command`.
+command() {
+ if [[ "$1" == "-v" ]]; then
+ local q="$2"
+ local m
+ for m in "${SHIM_MISSING_NAMES[@]:-}"; do
+ if [[ "$q" == "$m" ]]; then
+ return 1
+ fi
+ done
+ fi
+ builtin command "$@"
+}
+
+# Replace `name` with a stub that records argv and exits with `exit_code`
(default 0).
+shim_bin() {
+ local name="$1" exit_code="${2:-0}"
+ cat >"$SHIM_DIR/$name" <<EOF
+#!/usr/bin/env bash
+( IFS=\$'\t'; printf '%s\n' "\$*" ) >> "$SHIM_CALLS/$name.log"
+exit $exit_code
+EOF
+ chmod +x "$SHIM_DIR/$name"
+}
+
+# Replace `name` with a stub that records argv then runs an arbitrary shell
body.
+# Note: `body` must not contain a line that is exactly `EOF`, or it will
+# terminate the heredoc prematurely.
+shim_bin_script() {
+ local name="$1" body="$2"
+ cat >"$SHIM_DIR/$name" <<EOF
+#!/usr/bin/env bash
+( IFS=\$'\t'; printf '%s\n' "\$*" ) >> "$SHIM_CALLS/$name.log"
+$body
+EOF
+ chmod +x "$SHIM_DIR/$name"
+}
+
+# Make `name` resolve to "missing" for both `command -v` checks and actual
+# invocation: register it in SHIM_MISSING_NAMES (the `command` override
+# returns 1 for these) and drop an exit-127 stub on PATH (so direct
+# invocation doesn't accidentally hit the real system binary).
+shim_bin_missing() {
+ local name="$1"
+ SHIM_MISSING_NAMES+=("$name")
+ cat >"$SHIM_DIR/$name" <<'EOF'
+#!/usr/bin/env bash
+exit 127
+EOF
+ chmod +x "$SHIM_DIR/$name"
+}
+
+# Print all recorded calls for `name`, one per line, tab-separated argv.
+shim_calls() {
+ local name="$1"
+ cat "$SHIM_CALLS/$name.log" 2>/dev/null || true
+}
+
+# Print the number of times `name` was invoked.
+shim_call_count() {
+ local name="$1"
+ if [[ ! -f "$SHIM_CALLS/$name.log" ]]; then
+ echo 0
+ return
+ fi
+ wc -l < "$SHIM_CALLS/$name.log" | tr -d ' '
+}
diff --git a/tools/test/integration/.gitkeep b/tools/test/integration/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/tools/test/integration/bootstrap_gum_temp.bats
b/tools/test/integration/bootstrap_gum_temp.bats
new file mode 100644
index 00000000..c35ab9ff
--- /dev/null
+++ b/tools/test/integration/bootstrap_gum_temp.bats
@@ -0,0 +1,165 @@
+#!/usr/bin/env bats
+
+setup() {
+ load '../helpers/load'
+ load '../helpers/shim'
+ load_install_sh
+ reset_install_sh_state
+ shim_setup
+ # Force interactive-shell + tty path so bootstrap doesn't auto-skip.
+ NO_PROMPT=0
+ # gum_is_tty falls back to checking /dev/tty readability; bats provides
one.
+ # If the test env happens not to, override via TERM/NO_COLOR being unset.
+ unset NO_COLOR
+ TERM=xterm
+ # Make sure `gum` isn't already on PATH so we exercise the install branch.
+ shim_bin_missing gum
+ # Provide tar so the early "tar not found" branch is skipped.
+ shim_bin tar
+ # Real uname is fine here — we just need a supported os/arch.
+
+ # Override is_non_interactive_shell to report "interactive" by default so
+ # tests that exercise the download path are not short-circuited by stdin/
+ # stdout not being ttys in the bats process.
+ is_non_interactive_shell() { return 1; }
+
+ # Override gum_is_tty to report "has tty" by default so the second guard
+ # in bootstrap_gum_temp does not short-circuit the download path either.
+ gum_is_tty() { return 0; }
+}
+
+@test "bootstrap_gum_temp: non-interactive shell auto-skips with no downloads"
{
+ # Restore the real check and set NO_PROMPT to force non-interactive.
+ is_non_interactive_shell() {
+ [[ "${NO_PROMPT:-0}" == "1" ]]
+ }
+ NO_PROMPT=1
+ run bootstrap_gum_temp
+ [ "$status" -ne 0 ]
+ [ "$GUM" = "" ]
+ [ "$GUM_STATUS" = "skipped" ]
+ [ "$(shim_call_count curl)" = "0" ]
+ [ "$(shim_call_count wget)" = "0" ]
+}
+
+@test "bootstrap_gum_temp: download failure sets reason='download failed'" {
+ DOWNLOADER=curl
+ shim_bin curl 22
+ bootstrap_gum_temp || true
+ [ "$GUM" = "" ]
+ [ "$GUM_STATUS" = "skipped" ]
+ [ "$GUM_REASON" = "download failed" ]
+}
+
+@test "bootstrap_gum_temp: checksum failure sets reason='checksum unavailable
or failed'" {
+ DOWNLOADER=curl
+ # curl always succeeds (both asset and checksums.txt downloads succeed)
+ shim_bin curl
+ # sha256sum / shasum both fail
+ shim_bin sha256sum 1
+ shim_bin shasum 1
+ bootstrap_gum_temp || true
+ [ "$GUM_REASON" = "checksum unavailable or failed" ]
+}
+
+@test "bootstrap_gum_temp: extract failure sets reason='extract failed'" {
+ DOWNLOADER=curl
+ shim_bin curl
+ shim_bin sha256sum
+ shim_bin shasum
+ shim_bin tar 1
+ bootstrap_gum_temp || true
+ [ "$GUM_REASON" = "extract failed" ]
+}
+
+@test "bootstrap_gum_temp: missing gum binary after extract sets reason" {
+ DOWNLOADER=curl
+ shim_bin curl
+ shim_bin sha256sum
+ shim_bin shasum
+ # tar 'succeeds' but produces nothing
+ shim_bin tar
+ bootstrap_gum_temp || true
+ [ "$GUM_REASON" = "gum binary missing after extract" ]
+}
+
+@test "bootstrap_gum_temp: gum already on PATH is reported as found" {
+ # Clear the missing-names list so command -v gum sees the shim on PATH.
+ SHIM_MISSING_NAMES=()
+ shim_bin gum
+ bootstrap_gum_temp
+ [ "$GUM" = "gum" ]
+ [ "$GUM_STATUS" = "found" ]
+}
+
+@test "bootstrap_gum_temp: successful download+extract sets
GUM_STATUS=installed" {
+ DOWNLOADER=curl
+ # curl writes a dummy archive to the -o path
+ shim_bin_script curl '
+out=""; prev=""
+for a in "$@"; do
+ [[ "$prev" == "-o" ]] && out="$a"
+ prev="$a"
+done
+[[ -n "$out" ]] && printf "fake" > "$out"
+'
+ shim_bin sha256sum
+ shim_bin shasum
+ # tar extracts a fake gum binary into the temp dir
+ shim_bin_script tar '
+if [[ "$1" == "-xzf" ]]; then
+ dest="$4"
+ mkdir -p "$dest"
+ printf "#!/bin/bash\n" > "$dest/gum"
+ chmod +x "$dest/gum"
+fi
+'
+ bootstrap_gum_temp
+ [ "$GUM_STATUS" = "installed" ]
+ [ -n "$GUM" ]
+ [ -x "$GUM" ]
+}
+
+@test "bootstrap_gum_temp: cached binary on disk is reused without
downloading" {
+ DOWNLOADER=curl
+ # Pre-seed the persistent cache with an executable gum.
+ mkdir -p "${GUM_CACHE_ROOT}/${GUM_VERSION}"
+ printf '#!/bin/bash\n' > "${GUM_CACHE_ROOT}/${GUM_VERSION}/gum"
+ chmod +x "${GUM_CACHE_ROOT}/${GUM_VERSION}/gum"
+ # Any downloader call would be a regression — fail loudly if it happens.
+ shim_bin curl 22
+ shim_bin wget 22
+
+ bootstrap_gum_temp
+ [ "$GUM_STATUS" = "cached" ]
+ [ "$GUM" = "${GUM_CACHE_ROOT}/${GUM_VERSION}/gum" ]
+ [ -x "$GUM" ]
+ [ "$(shim_call_count curl)" = "0" ]
+ [ "$(shim_call_count wget)" = "0" ]
+}
+
+@test "bootstrap_gum_temp: successful install promotes binary into the cache" {
+ DOWNLOADER=curl
+ shim_bin_script curl '
+out=""; prev=""
+for a in "$@"; do
+ [[ "$prev" == "-o" ]] && out="$a"
+ prev="$a"
+done
+[[ -n "$out" ]] && printf "fake" > "$out"
+'
+ shim_bin sha256sum
+ shim_bin shasum
+ shim_bin_script tar '
+if [[ "$1" == "-xzf" ]]; then
+ dest="$4"
+ mkdir -p "$dest"
+ printf "#!/bin/bash\n" > "$dest/gum"
+ chmod +x "$dest/gum"
+fi
+'
+ bootstrap_gum_temp
+ [ "$GUM_STATUS" = "installed" ]
+ [ -x "${GUM_CACHE_ROOT}/${GUM_VERSION}/gum" ]
+ [ "$GUM" = "${GUM_CACHE_ROOT}/${GUM_VERSION}/gum" ]
+}
diff --git a/tools/test/integration/download_file.bats
b/tools/test/integration/download_file.bats
new file mode 100644
index 00000000..2b02d075
--- /dev/null
+++ b/tools/test/integration/download_file.bats
@@ -0,0 +1,88 @@
+#!/usr/bin/env bats
+
+setup() {
+ load '../helpers/load'
+ load '../helpers/shim'
+ load_install_sh
+ reset_install_sh_state
+ shim_setup
+}
+
+@test "download_file: curl is invoked with the exact expected argv" {
+ DOWNLOADER=curl
+ shim_bin curl
+
+ download_file "https://example.test/x.tgz" "$BATS_TEST_TMPDIR/out"
+
+ [ "$(shim_call_count curl)" = "1" ]
+ local got
+ got="$(shim_calls curl)"
+ local expected
+
expected=$'-fL\t--progress-bar\t--proto\t=https\t--tlsv1.2\t--retry\t3\t--max-time\t900\t--retry-delay\t1\t--retry-connrefused\t-o\t'"$BATS_TEST_TMPDIR/out"$'\thttps://example.test/x.tgz'
+ [ "$got" = "$expected" ]
+}
+
+@test "download_file: curl argv contains no stray '--' token (PR #599
regression guard)" {
+ DOWNLOADER=curl
+ shim_bin curl
+
+ download_file "https://example.test/x.tgz" "$BATS_TEST_TMPDIR/out"
+
+ local got
+ got="$(shim_calls curl)"
+ # A bare '--' token would appear as tab-bracketed or at line edges.
+ # Use POSIX [ ] with string-prefix stripping: if the pattern is absent,
+ # stripping it leaves got unchanged (strings are equal → [ ] succeeds).
+ # bash 3.2 does not trigger set -e for [[ ]] failures, so [ ] is required.
+ [ "${got#*$'\t--\t'}" = "$got" ]
+ [ "${got%$'\t--'}" = "$got" ]
+ [ "${got#'--'}" = "$got" ]
+}
+
+@test "download_file: curl failure propagates" {
+ DOWNLOADER=curl
+ shim_bin curl 22
+
+ run download_file "https://example.test/x.tgz" "$BATS_TEST_TMPDIR/out"
+ [ "$status" -ne 0 ]
+}
+
+@test "download_file: wget is invoked with the exact expected argv" {
+ DOWNLOADER=wget
+ shim_bin wget
+
+ download_file "https://example.test/x.tgz" "$BATS_TEST_TMPDIR/out"
+
+ [ "$(shim_call_count wget)" = "1" ]
+ local got
+ got="$(shim_calls wget)"
+ local expected
+
expected=$'-q\t--show-progress\t--https-only\t--secure-protocol=TLSv1_2\t--tries=3\t--timeout=900\t-O\t'"$BATS_TEST_TMPDIR/out"$'\thttps://example.test/x.tgz'
+ [ "$got" = "$expected" ]
+}
+
+@test "detect_downloader: picks curl when available" {
+ DOWNLOADER=""
+ shim_bin curl
+ shim_bin wget
+ detect_downloader
+ [ "$DOWNLOADER" = "curl" ]
+}
+
+@test "detect_downloader: falls back to wget when curl is missing" {
+ DOWNLOADER=""
+ shim_bin_missing curl
+ shim_bin wget
+ detect_downloader
+ [ "$DOWNLOADER" = "wget" ]
+}
+
+@test "detect_downloader: dies when neither curl nor wget is available" {
+ DOWNLOADER=""
+ shim_bin_missing curl
+ shim_bin_missing wget
+ run detect_downloader
+ [ "$status" -ne 0 ]
+ # Use POSIX [ ] for set -e compatibility on bash 3.2 (see also Test 2).
+ [ "${output#*Missing downloader}" != "$output" ]
+}
diff --git a/tools/test/integration/dry_run.bats
b/tools/test/integration/dry_run.bats
new file mode 100644
index 00000000..f1698214
--- /dev/null
+++ b/tools/test/integration/dry_run.bats
@@ -0,0 +1,54 @@
+#!/usr/bin/env bats
+
+setup() {
+ load '../helpers/load'
+ load '../helpers/shim'
+ shim_setup
+ # Fake FLINK_HOME so plan_flink (with INSTALL_FLINK=No) passes validation.
+ export FLINK_HOME="$BATS_TEST_TMPDIR/flink-home"
+ mkdir -p "$FLINK_HOME/lib"
+ # Fake `java` so check_java passes.
+ shim_bin_script java "
+case \"\$1\" in
+ -version) echo 'openjdk version \"17.0.2\"' >&2 ;;
+esac
+"
+}
+
+@test "--dry-run --non-interactive prints plan and makes no external calls" {
+ run bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run
--non-interactive
+ [ "$status" -eq 0 ]
+ case "$output" in *"Installation plan"*) ;; *) false ;; esac
+ case "$output" in *"Dry run complete"*) ;; *) false ;; esac
+ # No downloader should have been invoked.
+ [ "$(shim_call_count curl)" = "0" ]
+ [ "$(shim_call_count wget)" = "0" ]
+}
+
+@test "--dry-run --install-flink --non-interactive shows Install Flink: Yes" {
+ run bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --install-flink
--non-interactive
+ [ "$status" -eq 0 ]
+ case "$output" in *"Install Flink"*) ;; *) false ;; esac
+ case "$output" in *"Yes"*) ;; *) false ;; esac
+ case "$output" in *"Dry run complete"*) ;; *) false ;; esac
+}
+
+@test "INSTALL_DIR=. does not produce a double-slash FLINK_HOME (review
feedback guard)" {
+ cd "$BATS_TEST_TMPDIR"
+ run env INSTALL_DIR="." bash "${BATS_TEST_DIRNAME}/../../install.sh"
--dry-run --install-flink --non-interactive
+ [ "$status" -eq 0 ]
+ case "$output" in *".//flink-"*) false ;; *) ;; esac
+}
+
+@test "INSTALL_DIR with trailing slash does not produce double-slash (review
#7b)" {
+ run env INSTALL_DIR="/tmp/flink-test/" bash
"${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --install-flink
--non-interactive
+ [ "$status" -eq 0 ]
+ case "$output" in *"//"*) false ;; *) ;; esac
+ case "$output" in *"/tmp/flink-test"*) ;; *) false ;; esac
+}
+
+@test "INSTALL_DIR with consecutive slashes is collapsed (review #7b)" {
+ run env INSTALL_DIR="/tmp//flink-test" bash
"${BATS_TEST_DIRNAME}/../../install.sh" --dry-run --install-flink
--non-interactive
+ [ "$status" -eq 0 ]
+ case "$output" in *"//flink-"*) false ;; *) ;; esac
+}
diff --git a/tools/test/integration/dry_run_extra.bats
b/tools/test/integration/dry_run_extra.bats
new file mode 100644
index 00000000..1e7efac0
--- /dev/null
+++ b/tools/test/integration/dry_run_extra.bats
@@ -0,0 +1,42 @@
+#!/usr/bin/env bats
+
+setup() {
+ load '../helpers/load'
+ load '../helpers/shim'
+ shim_setup
+ export FLINK_HOME="$BATS_TEST_TMPDIR/flink-home"
+ mkdir -p "$FLINK_HOME/lib" "$FLINK_HOME/bin"
+ # Seed a fake flink-dist jar so detect_flink_version_from_home succeeds
+ # and the plan reflects the on-disk version (review feedback #2 + #3).
+ : > "$FLINK_HOME/lib/flink-dist-2.1.1.jar"
+ shim_bin_script java "
+case \"\$1\" in
+ -version) echo 'openjdk version \"17.0.2\"' >&2 ;;
+esac
+"
+}
+
+@test "dry-run: plan shows Flink Agents version (review #1 — JARs are
implicit)" {
+ run bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run
--non-interactive
+ [ "$status" -eq 0 ]
+ case "$output" in *"Flink Agents version"*"0.2.1"*) ;; *) false ;; esac
+}
+
+@test "dry-run: existing FLINK_HOME — plan shows detected version, not default
(review #2)" {
+ run bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run
--non-interactive
+ [ "$status" -eq 0 ]
+ case "$output" in *"v2.1.1"*) ;; *) false ;; esac
+}
+
+@test "dry-run: INSTALL_FLINK=No suppresses Install directory line (review
#3)" {
+ run bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run
--non-interactive
+ [ "$status" -eq 0 ]
+ case "$output" in *"Install directory"*) false ;; *) ;; esac
+}
+
+@test "dry-run: Environment section is shown separately from Plan (review #5)"
{
+ run bash "${BATS_TEST_DIRNAME}/../../install.sh" --dry-run
--non-interactive
+ [ "$status" -eq 0 ]
+ case "$output" in *"Environment (read-only)"*) ;; *) false ;; esac
+ case "$output" in *"Installation plan"*) ;; *) false ;; esac
+}
diff --git a/tools/test/integration/err_trap.bats
b/tools/test/integration/err_trap.bats
new file mode 100644
index 00000000..bc553c53
--- /dev/null
+++ b/tools/test/integration/err_trap.bats
@@ -0,0 +1,39 @@
+#!/usr/bin/env bats
+
+# When a command fails under `set -e` (i.e. NOT through die()/die_cancelled),
+# the ERR trap should print a banner that names the stage, the command,
+# the install.sh line, and the exit code. die() must keep its existing
+# single-line message and not trip the banner.
+
+@test "on_error: set -e failure triggers a stage-aware banner" {
+ # Use a real failure mode: pretend a Flink download 404'd. The script
+ # walks through plan_flink with INSTALL_FLINK=Yes (default INSTALL_DIR,
+ # but we override it to /tmp) and then dies on curl in stage 3.
+ local tmp="$BATS_TEST_TMPDIR/demo"
+ mkdir -p "$tmp"
+ run env INSTALL_DIR="$tmp" FLINK_BASE_URL="https://dlcdn.apache.org/flink"
\
+ FLINK_VERSION=99.99.0 \
+ bash "${BATS_TEST_DIRNAME}/../../install.sh" \
+ --install-flink --non-interactive
+ [ "$status" -ne 0 ]
+ # Banner must name the stage by title.
+ case "$output" in *"Installation failed at stage 3/5"*) ;; *) false ;; esac
+ case "$output" in *"Installing Apache Flink"*) ;; *) false ;; esac
+ # Banner must include source line + exit code keywords.
+ case "$output" in *"Source:"*"install.sh:"*) ;; *) false ;; esac
+ case "$output" in *"Exit code:"*) ;; *) false ;; esac
+ # Banner must not point at the trap line itself, which would be a
+ # regression — we want the line where the failing command lives.
+ case "$output" in *"install.sh:0"*) false ;; *) ;; esac
+}
+
+@test "die(): single-line message, NO banner duplication" {
+ # die() uses `exit`, which doesn't trigger ERR. Reach it by giving
+ # plan_flink a non-existent FLINK_HOME under --non-interactive.
+ run env FLINK_HOME="" \
+ bash "${BATS_TEST_DIRNAME}/../../install.sh" --non-interactive
+ [ "$status" -ne 0 ]
+ case "$output" in *"FLINK_HOME is not set"*) ;; *) false ;; esac
+ # Banner box characters must NOT appear in a die() path.
+ case "$output" in *"Installation failed at stage"*) false ;; *) ;; esac
+}
diff --git a/tools/test/integration/help.bats b/tools/test/integration/help.bats
new file mode 100644
index 00000000..d77335e3
--- /dev/null
+++ b/tools/test/integration/help.bats
@@ -0,0 +1,16 @@
+#!/usr/bin/env bats
+
+@test "--help prints usage and exits 0" {
+ run bash "${BATS_TEST_DIRNAME}/../../install.sh" --help
+ [ "$status" -eq 0 ]
+ case "$output" in *"Apache Flink Agents Installer"*) ;; *) false ;; esac
+ case "$output" in *"Options:"*) ;; *) false ;; esac
+ case "$output" in *"--install-flink"*) ;; *) false ;; esac
+ case "$output" in *"--dry-run"*) ;; *) false ;; esac
+}
+
+@test "-h prints usage and exits 0" {
+ run bash "${BATS_TEST_DIRNAME}/../../install.sh" -h
+ [ "$status" -eq 0 ]
+ case "$output" in *"Apache Flink Agents Installer"*) ;; *) false ;; esac
+}
diff --git a/tools/test/integration/install_flink_agents_jar.bats
b/tools/test/integration/install_flink_agents_jar.bats
new file mode 100644
index 00000000..146967cc
--- /dev/null
+++ b/tools/test/integration/install_flink_agents_jar.bats
@@ -0,0 +1,98 @@
+#!/usr/bin/env bats
+
+setup() {
+ load '../helpers/load'
+ load '../helpers/shim'
+ load_install_sh
+ reset_install_sh_state
+ shim_setup
+ FLINK_HOME="$BATS_TEST_TMPDIR/flink-home"
+ mkdir -p "$FLINK_HOME/lib"
+ FLINK_VERSION="2.2.0"
+ FLINK_MAJOR_MINOR="2.2"
+ FLINK_AGENTS_VERSION="0.2.1"
+ FLINK_AGENTS_BASE_URL="https://mirror.test/flink"
+ FLINK_AGENTS_CHECKSUM_BASE_URL="https://downloads.test/flink"
+}
+
+# A curl shim that writes whatever was requested at -o into a fake file
+# whose body is "fake-jar-<basename>" — enough to satisfy the "non-empty"
+# check, and lets us verify which URLs were requested.
+configure_curl_shim() {
+ shim_bin_script curl '
+out=""; url=""; prev=""
+for a in "$@"; do
+ [[ "$prev" == "-o" ]] && out="$a"
+ case "$a" in -*|"") ;; *) url="$a" ;; esac
+ prev="$a"
+done
+[[ -n "$out" ]] && printf "fake-content-%s" "$(basename "$out")" > "$out"
+'
+}
+
+@test "flink_agents_jar_relpath: builds expected ASF mirror path" {
+ FLINK_MAJOR_MINOR="2.1"
+ FLINK_AGENTS_VERSION="0.2.0"
+ run flink_agents_jar_relpath
+ [ "$status" -eq 0 ]
+ [ "$output" = "flink-agents-0.2.0/flink-agents-dist-flink-2.1-0.2.0.jar" ]
+}
+
+@test "install_flink_agents_jar: downloads from mirror, verifies, lands in
FLINK_HOME/lib" {
+ DOWNLOADER=curl
+ configure_curl_shim
+
+ run install_flink_agents_jar
+ [ "$status" -eq 0 ]
+ [ -f "$FLINK_HOME/lib/flink-agents-dist-flink-2.2-0.2.1.jar" ]
+
+ # Two curl calls expected: jar from mirror, sha512 sidecar from
downloads.apache.org.
+ case "$(shim_calls curl)" in
+
*"https://mirror.test/flink/flink-agents-0.2.1/flink-agents-dist-flink-2.2-0.2.1.jar"*)
;;
+ *) false ;;
+ esac
+ case "$(shim_calls curl)" in
+
*"https://downloads.test/flink/flink-agents-0.2.1/flink-agents-dist-flink-2.2-0.2.1.jar.sha512"*)
;;
+ *) false ;;
+ esac
+}
+
+@test "install_flink_agents_jar: reuses existing JAR, skips re-download" {
+ DOWNLOADER=curl
+ configure_curl_shim
+ # Pre-seed the target JAR.
+ : > "$FLINK_HOME/lib/flink-agents-dist-flink-2.2-0.2.1.jar"
+
+ run install_flink_agents_jar
+ [ "$status" -eq 0 ]
+ # Only the sha512 sidecar should have been fetched, never the JAR itself.
+ case "$(shim_calls curl)" in
+
*"flink-agents-dist-flink-2.2-0.2.1.jar"*"flink-agents-dist-flink-2.2-0.2.1.jar"*)
false ;;
+ *) ;;
+ esac
+}
+
+@test "install_flink_agents_jar: empty downloaded file is a hard error" {
+ DOWNLOADER=curl
+ # Shim that "succeeds" but writes nothing.
+ shim_bin_script curl '
+out=""; prev=""
+for a in "$@"; do
+ [[ "$prev" == "-o" ]] && out="$a"
+ prev="$a"
+done
+[[ -n "$out" ]] && : > "$out"
+'
+ run install_flink_agents_jar
+ [ "$status" -ne 0 ]
+ case "$output" in *"empty file"*) ;; *) false ;; esac
+ [ ! -f "$FLINK_HOME/lib/flink-agents-dist-flink-2.2-0.2.1.jar" ]
+}
+
+@test "install_flink_agents_jar: download failure surfaces a clean error" {
+ DOWNLOADER=curl
+ shim_bin curl 22 # curl exit 22 = HTTP error
+ run install_flink_agents_jar
+ [ "$status" -ne 0 ]
+ case "$output" in *"Failed to download flink-agents JAR"*) ;; *) false ;;
esac
+}
diff --git a/tools/test/integration/install_flink_if_needed.bats
b/tools/test/integration/install_flink_if_needed.bats
new file mode 100644
index 00000000..7bbdceee
--- /dev/null
+++ b/tools/test/integration/install_flink_if_needed.bats
@@ -0,0 +1,132 @@
+#!/usr/bin/env bats
+
+setup() {
+ load '../helpers/load'
+ load '../helpers/shim'
+ load_install_sh
+ reset_install_sh_state
+ shim_setup
+ INSTALL_DIR="$BATS_TEST_TMPDIR/install"
+ FLINK_VERSION="2.2.0"
+ FLINK_SCALA_VERSION="2.12"
+ FLINK_BASE_URL="https://example.test/flink"
+}
+
+# A tar shim that succeeds for both `-tzf` (validity check) and `-xzf`
(extract).
+# On extract it materializes a minimal Flink home with a lib/ dir.
+configure_tar_shim() {
+ shim_bin_script tar "
+case \"\$1\" in
+ -tzf) exit 0 ;;
+ -xzf)
+ # arg order: -xzf <archive> -C <dest> (\$4 = dest)
+ mkdir -p \"\$4/flink-${FLINK_VERSION}/lib\"
+ ;;
+ *) exit 0 ;;
+esac
+"
+}
+
+@test "install_flink_if_needed: INSTALL_FLINK=No uses pre-existing FLINK_HOME"
{
+ INSTALL_FLINK=No
+ FLINK_HOME="$BATS_TEST_TMPDIR/preexisting"
+ mkdir -p "$FLINK_HOME/lib"
+ run install_flink_if_needed
+ [ "$status" -eq 0 ]
+ [ "$(shim_call_count curl)" = "0" ]
+ [ "$(shim_call_count wget)" = "0" ]
+}
+
+@test "install_flink_if_needed: fresh install downloads then extracts" {
+ INSTALL_FLINK=Yes
+ DOWNLOADER=curl
+ FLINK_HOME="$INSTALL_DIR/flink-$FLINK_VERSION"
+ # curl shim writes a placeholder archive at the requested -o path.
+ shim_bin_script curl "
+# argv pattern: ... -o <output> <url>
+out=\"\"
+prev=\"\"
+for a in \"\$@\"; do
+ if [[ \"\$prev\" == \"-o\" ]]; then out=\"\$a\"; fi
+ prev=\"\$a\"
+done
+[[ -n \"\$out\" ]] && printf 'fake tgz' > \"\$out\"
+"
+ configure_tar_shim
+
+ run install_flink_if_needed
+ [ "$status" -eq 0 ]
+ [ "$(shim_call_count curl)" = "1" ]
+ case "$(shim_calls curl)" in
+
*"https://example.test/flink/flink-2.2.0/flink-2.2.0-bin-scala_2.12.tgz"*) ;;
+ *) false ;;
+ esac
+ [ -d "$INSTALL_DIR/flink-$FLINK_VERSION/lib" ]
+}
+
+@test "install_flink_if_needed: existing valid archive is reused (no
download)" {
+ INSTALL_FLINK=Yes
+ DOWNLOADER=curl
+ mkdir -p "$INSTALL_DIR"
+ # Pre-seed a "valid" archive — tar shim will report it as valid via -tzf.
+ printf 'existing' >
"$INSTALL_DIR/flink-${FLINK_VERSION}-bin-scala_${FLINK_SCALA_VERSION}.tgz"
+ shim_bin curl
+ configure_tar_shim
+ FLINK_HOME="$INSTALL_DIR/flink-$FLINK_VERSION"
+
+ run install_flink_if_needed
+ [ "$status" -eq 0 ]
+ [ "$(shim_call_count curl)" = "0" ]
+}
+
+@test "install_flink_if_needed: corrupt existing archive triggers re-download"
{
+ INSTALL_FLINK=Yes
+ DOWNLOADER=curl
+ mkdir -p "$INSTALL_DIR"
+ local
archive="$INSTALL_DIR/flink-${FLINK_VERSION}-bin-scala_${FLINK_SCALA_VERSION}.tgz"
+ printf 'existing' > "$archive"
+ # tar reports the existing archive as INVALID, then valid on the
re-download.
+ local state="$BATS_TEST_TMPDIR/tar_state"
+ : > "$state"
+ shim_bin_script tar "
+case \"\$1\" in
+ -tzf)
+ if [[ ! -s '$state' ]]; then
+ echo bad > '$state'
+ exit 1
+ fi
+ exit 0
+ ;;
+ -xzf)
+ mkdir -p \"\$4/flink-${FLINK_VERSION}/lib\"
+ ;;
+esac
+"
+ shim_bin_script curl "
+out=\"\"; prev=\"\"
+for a in \"\$@\"; do
+ [[ \"\$prev\" == \"-o\" ]] && out=\"\$a\"
+ prev=\"\$a\"
+done
+[[ -n \"\$out\" ]] && printf 'freshly downloaded' > \"\$out\"
+"
+ FLINK_HOME="$INSTALL_DIR/flink-$FLINK_VERSION"
+
+ run install_flink_if_needed
+ [ "$status" -eq 0 ]
+ [ "$(shim_call_count curl)" = "1" ]
+}
+
+@test "install_flink_if_needed: incomplete extracted dir triggers re-extract" {
+ INSTALL_FLINK=Yes
+ DOWNLOADER=curl
+ mkdir -p "$INSTALL_DIR/flink-${FLINK_VERSION}" # missing lib/
+ printf 'archive' >
"$INSTALL_DIR/flink-${FLINK_VERSION}-bin-scala_${FLINK_SCALA_VERSION}.tgz"
+ shim_bin curl
+ configure_tar_shim
+ FLINK_HOME="$INSTALL_DIR/flink-$FLINK_VERSION"
+
+ run install_flink_if_needed
+ [ "$status" -eq 0 ]
+ [ -d "$INSTALL_DIR/flink-$FLINK_VERSION/lib" ]
+}
diff --git a/tools/test/integration/venv_dir_validation.bats
b/tools/test/integration/venv_dir_validation.bats
new file mode 100644
index 00000000..55fc2315
--- /dev/null
+++ b/tools/test/integration/venv_dir_validation.bats
@@ -0,0 +1,61 @@
+#!/usr/bin/env bats
+
+# End-to-end behavior of VENV_DIR validation. In non-interactive mode
+# (the only mode the integration runner can drive), passing a foreign
+# non-empty directory as VENV_DIR must abort BEFORE downloading Flink.
+
+setup() {
+ load '../helpers/load'
+ load '../helpers/shim'
+ shim_setup
+ # Fake an existing Flink so plan_flink (INSTALL_FLINK=No) passes.
+ export FLINK_HOME="$BATS_TEST_TMPDIR/flink-home"
+ mkdir -p "$FLINK_HOME/lib"
+ : > "$FLINK_HOME/lib/flink-dist-2.2.0.jar"
+ # Fake java so check_java passes.
+ shim_bin_script java "
+case \"\$1\" in
+ -version) echo 'openjdk version \"17.0.2\"' >&2 ;;
+esac
+"
+}
+
+@test "VENV_DIR=non-empty foreign dir + --non-interactive → die before
download" {
+ local foreign="$BATS_TEST_TMPDIR/foreign"
+ mkdir -p "$foreign"
+ : > "$foreign/unrelated.txt"
+
+ run env VENV_DIR="$foreign" PYTHON_BIN=/no/such/python3 \
+ bash "${BATS_TEST_DIRNAME}/../../install.sh" \
+ --non-interactive --enable-pyflink
+
+ [ "$status" -ne 0 ]
+ case "$output" in
+ *"already exists and is not a Python venv"*) ;;
+ *) false ;;
+ esac
+ # Critical: must NOT have invoked any downloader before failing.
+ [ "$(shim_call_count curl)" = "0" ]
+ [ "$(shim_call_count wget)" = "0" ]
+}
+
+@test "VENV_DIR=existing real venv (has pyvenv.cfg) is accepted in
non-interactive mode" {
+ local venv="$BATS_TEST_TMPDIR/real-venv"
+ mkdir -p "$venv/bin"
+ : > "$venv/pyvenv.cfg"
+ : > "$venv/bin/activate"
+
+ # We don't actually want it to *install* — just to get past the
+ # plan_pyflink validation. Use --dry-run so plan succeeds and we
+ # bail before stage 3.
+ run env VENV_DIR="$venv" PYTHON_BIN=/no/such/python3 \
+ bash "${BATS_TEST_DIRNAME}/../../install.sh" \
+ --non-interactive --enable-pyflink --dry-run
+ # Either the dry-run printout completes (status 0) or fails for an
+ # unrelated reason (e.g. PYTHON_BIN missing in non-interactive
resolve_python).
+ # The thing we explicitly want NOT to see: the VENV_DIR rejection.
+ case "$output" in
+ *"is not a Python venv"*) false ;;
+ *) ;;
+ esac
+}
diff --git a/tools/test/run.sh b/tools/test/run.sh
new file mode 100755
index 00000000..c5d303d7
--- /dev/null
+++ b/tools/test/run.sh
@@ -0,0 +1,53 @@
+#!/usr/bin/env bash
+################################################################################
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+
+# Bash 4+ is required: bash 3.2 (macOS default) does not trigger `set -e`
+# on `[[ ]]` failures or fire the ERR trap on them, which means many
+# substring assertions in this suite would silently pass on bash 3.2.
+# Force a clean failure here rather than mislead developers.
+if [ -z "${BASH_VERSION:-}" ] || [ "${BASH_VERSION%%.*}" -lt 4 ]; then
+ echo "ERROR: bash >= 4 required (detected: ${BASH_VERSION:-unknown})." >&2
+ echo "macOS ships bash 3.2 at /bin/bash; install bash 4+ via Homebrew:" >&2
+ echo " brew install bash" >&2
+ echo "Then run with the new bash, e.g.:" >&2
+ echo " /opt/homebrew/bin/bash $0" >&2
+ exit 1
+fi
+
+set -euo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+CACHE="$HERE/.bats-cache"
+
+clone_pinned() {
+ local name="$1" url="$2" tag="$3"
+ if [[ ! -d "$CACHE/$name" ]]; then
+ echo "Fetching $name@$tag" >&2
+ git clone --quiet --depth 1 --branch "$tag" "$url" "$CACHE/$name"
+ fi
+}
+
+mkdir -p "$CACHE"
+clone_pinned bats-core https://github.com/bats-core/bats-core.git v1.11.0
+clone_pinned bats-support https://github.com/bats-core/bats-support.git v0.3.0
+clone_pinned bats-assert https://github.com/bats-core/bats-assert.git v2.1.0
+
+export BATS_LIB_PATH="$CACHE"
+
+exec "$CACHE/bats-core/bin/bats" --recursive "$HERE/unit" "$HERE/integration"
diff --git a/tools/test/unit/detect_flink_version_from_home.bats
b/tools/test/unit/detect_flink_version_from_home.bats
new file mode 100644
index 00000000..546bb51a
--- /dev/null
+++ b/tools/test/unit/detect_flink_version_from_home.bats
@@ -0,0 +1,114 @@
+#!/usr/bin/env bats
+
+setup() {
+ load '../helpers/load'
+ load_install_sh
+ reset_install_sh_state
+ FLINK_HOME="$BATS_TEST_TMPDIR/flink-home"
+ mkdir -p "$FLINK_HOME/bin" "$FLINK_HOME/lib"
+}
+
+@test "detect_flink_version_from_home: prefers bin/flink --version when
runnable" {
+ cat > "$FLINK_HOME/bin/flink" <<'EOF'
+#!/usr/bin/env bash
+echo "Version: 2.1.1, Commit ID: abc"
+EOF
+ chmod +x "$FLINK_HOME/bin/flink"
+ FLINK_VERSION=""
+ run detect_flink_version_from_home
+ [ "$status" -eq 0 ]
+ [ "$FLINK_VERSION" = "2.1.1" ] || {
+ # `run` executes in subshell — re-run inline to read the assignment.
+ FLINK_VERSION=""
+ detect_flink_version_from_home
+ [ "$FLINK_VERSION" = "2.1.1" ]
+ }
+}
+
+@test "detect_flink_version_from_home: falls back to lib/flink-dist-*.jar" {
+ : > "$FLINK_HOME/lib/flink-dist-2.0.1.jar"
+ FLINK_VERSION=""
+ detect_flink_version_from_home
+ [ "$FLINK_VERSION" = "2.0.1" ]
+}
+
+@test "detect_flink_version_from_home: returns non-zero when no source
available" {
+ FLINK_VERSION=""
+ run detect_flink_version_from_home
+ [ "$status" -ne 0 ]
+}
+
+@test "detect_flink_version_from_home: ignores stderr noise from flink
--version" {
+ cat > "$FLINK_HOME/bin/flink" <<'EOF'
+#!/usr/bin/env bash
+echo "WARNING: some noise" >&2
+echo "Version: 1.20.3, Commit ID: deadbeef"
+EOF
+ chmod +x "$FLINK_HOME/bin/flink"
+ FLINK_VERSION=""
+ detect_flink_version_from_home
+ [ "$FLINK_VERSION" = "1.20.3" ]
+}
+
+@test "detect_flink_version_from_home: jar fallback picks first match
deterministically" {
+ : > "$FLINK_HOME/lib/flink-dist-2.2.0.jar"
+ : > "$FLINK_HOME/lib/flink-dist-other-1.0.jar"
+ FLINK_VERSION=""
+ detect_flink_version_from_home
+ [ "$FLINK_VERSION" = "2.2.0" ]
+}
+
+@test "detect_flink_version_from_home: announces progress before invoking
bin/flink" {
+ # No dist jar in lib/ -> forces the slow path that runs bin/flink
--version.
+ cat > "$FLINK_HOME/bin/flink" <<'EOF'
+#!/usr/bin/env bash
+echo "Version: 2.1.1, Commit ID: abc"
+EOF
+ chmod +x "$FLINK_HOME/bin/flink"
+ FLINK_VERSION=""
+ run detect_flink_version_from_home
+ [ "$status" -eq 0 ]
+ case "$output" in *"Detecting Flink version"*) ;; *) false ;; esac
+}
+
+@test "detect_flink_version_from_home: fast jar path does NOT announce
progress" {
+ : > "$FLINK_HOME/lib/flink-dist-2.0.1.jar"
+ FLINK_VERSION=""
+ run detect_flink_version_from_home
+ [ "$status" -eq 0 ]
+ case "$output" in *"Detecting Flink version"*) false ;; *) ;; esac
+}
+
+@test "detect_flink_version_from_home: accepts X.Y-SNAPSHOT from local source
builds" {
+ : > "$FLINK_HOME/lib/flink-dist-2.2-SNAPSHOT.jar"
+ FLINK_VERSION=""
+ detect_flink_version_from_home
+ [ "$FLINK_VERSION" = "2.2-SNAPSHOT" ]
+}
+
+@test "detect_flink_version_from_home: accepts X.Y.Z-SNAPSHOT" {
+ : > "$FLINK_HOME/lib/flink-dist-2.1.0-SNAPSHOT.jar"
+ FLINK_VERSION=""
+ detect_flink_version_from_home
+ [ "$FLINK_VERSION" = "2.1.0-SNAPSHOT" ]
+}
+
+@test "detect_flink_version_from_home: accepts -rc suffix from CLI output" {
+ cat > "$FLINK_HOME/bin/flink" <<'EOF'
+#!/usr/bin/env bash
+echo "Version: 2.0.0-rc1, Commit ID: abc"
+EOF
+ chmod +x "$FLINK_HOME/bin/flink"
+ FLINK_VERSION=""
+ detect_flink_version_from_home
+ [ "$FLINK_VERSION" = "2.0.0-rc1" ]
+}
+
+@test "flink_major_minor: derives X.Y from various version strings" {
+ [ "$(flink_major_minor "2.2.0")" = "2.2" ]
+ [ "$(flink_major_minor "2.2.0-SNAPSHOT")" = "2.2" ]
+ [ "$(flink_major_minor "2.2-SNAPSHOT")" = "2.2" ]
+ [ "$(flink_major_minor "1.20.3")" = "1.20" ]
+ [ "$(flink_major_minor "2.1-rc1")" = "2.1" ]
+ [ "$(flink_major_minor "garbage")" = "" ]
+}
diff --git a/tools/test/unit/edit_plan_back.bats
b/tools/test/unit/edit_plan_back.bats
new file mode 100644
index 00000000..40c4796b
--- /dev/null
+++ b/tools/test/unit/edit_plan_back.bats
@@ -0,0 +1,69 @@
+#!/usr/bin/env bats
+
+setup() {
+ load '../helpers/load'
+ load_install_sh
+ reset_install_sh_state
+}
+
+# When a sub-prompt's ESC propagates `exit 130` out of edit_plan_interactive's
+# subshell, the surrounding `set -e` MUST NOT kill the installer. The wrapper
+# should swallow the non-zero exit and return 0 ("back to confirm").
+
+@test "edit_plan_interactive: subshell exit 130 is treated as back, not as
installer kill" {
+ # Replace edit_plan_interactive with a stand-in that runs a subshell
+ # which exits 130 the same way the real ESC path does, and uses the
+ # exact `|| rc=$?` pattern we ship.
+ fake_edit() {
+ local rc=0
+ (
+ exit 130
+ ) || rc=$?
+ if (( rc != 0 )); then
+ return 0
+ fi
+ return 99 # we should never get here
+ }
+
+ # If `set -e` were still propagating the 130, the test process would
+ # die here. Reaching the assertion means the pattern works.
+ run fake_edit
+ [ "$status" -eq 0 ]
+}
+
+@test "edit_plan_interactive: subshell exit 1 (gum ESC) also becomes back" {
+ fake_edit() {
+ local rc=0
+ ( exit 1 ) || rc=$?
+ if (( rc != 0 )); then
+ return 0
+ fi
+ return 99
+ }
+ run fake_edit
+ [ "$status" -eq 0 ]
+}
+
+@test "edit_plan_interactive: subshell exit 0 sources state and propagates
changes" {
+ local state_file="$BATS_TEST_TMPDIR/state"
+ cat > "$state_file" <<EOF
+INSTALL_FLINK='Yes'
+FLINK_VERSION='9.9.9'
+EOF
+
+ fake_edit() {
+ local rc=0
+ ( : ) || rc=$? # subshell that succeeds
+ if (( rc != 0 )); then
+ return 0
+ fi
+ # shellcheck disable=SC1090
+ source "$state_file"
+ }
+
+ INSTALL_FLINK="No"
+ FLINK_VERSION="0.0.0"
+ fake_edit
+ [ "$INSTALL_FLINK" = "Yes" ]
+ [ "$FLINK_VERSION" = "9.9.9" ]
+}
diff --git a/tools/test/unit/edit_plan_quote.bats
b/tools/test/unit/edit_plan_quote.bats
new file mode 100644
index 00000000..426b1d59
--- /dev/null
+++ b/tools/test/unit/edit_plan_quote.bats
@@ -0,0 +1,88 @@
+#!/usr/bin/env bats
+
+setup() {
+ load '../helpers/load'
+ load_install_sh
+ reset_install_sh_state
+}
+
+# edit_plan_quote single-quotes its argument so the parent shell can
+# safely `source` the dumped state file. Round-tripping the output via
+# eval should reproduce the original string exactly.
+
+@test "edit_plan_quote: empty string round-trips" {
+ local quoted
+ quoted="$(edit_plan_quote "")"
+ local out
+ eval "out=$quoted"
+ [ "$out" = "" ]
+}
+
+@test "edit_plan_quote: plain path round-trips" {
+ local quoted
+ quoted="$(edit_plan_quote "/opt/flink")"
+ local out
+ eval "out=$quoted"
+ [ "$out" = "/opt/flink" ]
+}
+
+@test "edit_plan_quote: path with spaces round-trips" {
+ local quoted
+ quoted="$(edit_plan_quote "/home/jin doe/flink")"
+ local out
+ eval "out=$quoted"
+ [ "$out" = "/home/jin doe/flink" ]
+}
+
+@test "edit_plan_quote: path with single quote round-trips" {
+ local input="/tmp/it's-fine"
+ local quoted
+ quoted="$(edit_plan_quote "$input")"
+ local out
+ eval "out=$quoted"
+ [ "$out" = "$input" ]
+}
+
+@test "edit_plan_quote: shell metacharacters are not expanded on source-back" {
+ local input='/tmp/$HOME-or-$(rm -rf /)'
+ local quoted
+ quoted="$(edit_plan_quote "$input")"
+ local out
+ eval "out=$quoted"
+ [ "$out" = "$input" ]
+}
+
+@test "edit_plan_dump_state: writes a sourceable file that restores values" {
+ INSTALL_FLINK="No"
+ FLINK_VERSION="2.1.1"
+ INSTALL_DIR="/tmp/old"
+ FLINK_HOME="/usr/local/flink"
+ FLINK_MAJOR_MINOR="2.1"
+ ENABLE_PYFLINK="Yes"
+ PYFLINK_ACTUALLY_ENABLED=1
+ VENV_DIR="/tmp/venv with space"
+ PYTHON_BIN="/usr/bin/python3"
+ FLINK_AGENTS_VERSION="0.2.0"
+
+ local f="$BATS_TEST_TMPDIR/state"
+ edit_plan_dump_state "$f"
+
+ # Clobber the live values, then re-source.
+ INSTALL_FLINK=""; FLINK_VERSION=""; INSTALL_DIR=""; FLINK_HOME=""
+ FLINK_MAJOR_MINOR=""; ENABLE_PYFLINK=""; PYFLINK_ACTUALLY_ENABLED=0
+ VENV_DIR=""; PYTHON_BIN=""; FLINK_AGENTS_VERSION=""
+
+ # shellcheck disable=SC1090
+ source "$f"
+
+ [ "$INSTALL_FLINK" = "No" ]
+ [ "$FLINK_VERSION" = "2.1.1" ]
+ [ "$INSTALL_DIR" = "/tmp/old" ]
+ [ "$FLINK_HOME" = "/usr/local/flink" ]
+ [ "$FLINK_MAJOR_MINOR" = "2.1" ]
+ [ "$ENABLE_PYFLINK" = "Yes" ]
+ [ "$PYFLINK_ACTUALLY_ENABLED" = "1" ]
+ [ "$VENV_DIR" = "/tmp/venv with space" ]
+ [ "$PYTHON_BIN" = "/usr/bin/python3" ]
+ [ "$FLINK_AGENTS_VERSION" = "0.2.0" ]
+}
diff --git a/tools/test/unit/is_snapshot_version.bats
b/tools/test/unit/is_snapshot_version.bats
new file mode 100644
index 00000000..8f387886
--- /dev/null
+++ b/tools/test/unit/is_snapshot_version.bats
@@ -0,0 +1,43 @@
+#!/usr/bin/env bats
+
+# is_snapshot_version returns 0 (true) for any non-release Flink version
+# string — anything bearing a "-SNAPSHOT", "-rc", "-dev", etc. suffix.
+# Pure release versions like 2.2.0 return 1 (false).
+#
+# Used by setup_python_env to decide whether to ask PyPI for an exact
+# match (apache-flink==X.Y.Z) or a compatible release (apache-flink~=X.Y.0)
+# when the user is running against a source-built Flink.
+
+setup() {
+ load '../helpers/load'
+ load_install_sh
+ reset_install_sh_state
+}
+
+@test "is_snapshot_version: SNAPSHOT suffix → true" {
+ run is_snapshot_version "2.2-SNAPSHOT"
+ [ "$status" -eq 0 ]
+ run is_snapshot_version "2.1.0-SNAPSHOT"
+ [ "$status" -eq 0 ]
+}
+
+@test "is_snapshot_version: rc / beta / dev suffix → true" {
+ run is_snapshot_version "2.0.0-rc1"
+ [ "$status" -eq 0 ]
+ run is_snapshot_version "1.20.3-beta-2"
+ [ "$status" -eq 0 ]
+ run is_snapshot_version "2.1.0-dev"
+ [ "$status" -eq 0 ]
+}
+
+@test "is_snapshot_version: plain release versions → false" {
+ run is_snapshot_version "2.2.0"
+ [ "$status" -eq 1 ]
+ run is_snapshot_version "1.20.3"
+ [ "$status" -eq 1 ]
+}
+
+@test "is_snapshot_version: empty string → false (defensive)" {
+ run is_snapshot_version ""
+ [ "$status" -eq 1 ]
+}
diff --git a/tools/test/unit/is_valid_tgz.bats
b/tools/test/unit/is_valid_tgz.bats
new file mode 100644
index 00000000..784022b8
--- /dev/null
+++ b/tools/test/unit/is_valid_tgz.bats
@@ -0,0 +1,44 @@
+#!/usr/bin/env bats
+
+setup() {
+ load '../helpers/load'
+ load_install_sh
+ reset_install_sh_state
+ WORK="$BATS_TEST_TMPDIR/tgz"
+ mkdir -p "$WORK"
+}
+
+@test "is_valid_tgz: missing file is invalid" {
+ run is_valid_tgz "$WORK/nope.tgz"
+ [ "$status" -ne 0 ]
+}
+
+@test "is_valid_tgz: empty file is invalid" {
+ printf '\x00' > "$WORK/empty.tgz"
+ run is_valid_tgz "$WORK/empty.tgz"
+ [ "$status" -ne 0 ]
+}
+
+@test "is_valid_tgz: random bytes are invalid" {
+ printf 'not a tarball at all\n' > "$WORK/junk.tgz"
+ run is_valid_tgz "$WORK/junk.tgz"
+ [ "$status" -ne 0 ]
+}
+
+@test "is_valid_tgz: a real tgz is valid" {
+ mkdir -p "$WORK/src"
+ echo hello > "$WORK/src/file.txt"
+ tar -czf "$WORK/good.tgz" -C "$WORK/src" .
+ run is_valid_tgz "$WORK/good.tgz"
+ [ "$status" -eq 0 ]
+}
+
+@test "is_valid_tgz: truncated tgz is invalid" {
+ mkdir -p "$WORK/src"
+ head -c 4096 /dev/urandom > "$WORK/src/blob"
+ tar -czf "$WORK/full.tgz" -C "$WORK/src" .
+ # truncate to first 32 bytes (gzip header only)
+ head -c 32 "$WORK/full.tgz" > "$WORK/trunc.tgz"
+ run is_valid_tgz "$WORK/trunc.tgz"
+ [ "$status" -ne 0 ]
+}
diff --git a/tools/test/unit/mark_explicit.bats
b/tools/test/unit/mark_explicit.bats
new file mode 100644
index 00000000..68a1111f
--- /dev/null
+++ b/tools/test/unit/mark_explicit.bats
@@ -0,0 +1,31 @@
+#!/usr/bin/env bats
+
+setup() {
+ load '../helpers/load'
+ load_install_sh
+ reset_install_sh_state
+}
+
+@test "mark_explicit: unset variable yields _EXPLICIT=0" {
+ unset MY_VAR MY_VAR_EXPLICIT
+ mark_explicit MY_VAR
+ [ "$MY_VAR_EXPLICIT" = "0" ]
+}
+
+@test "mark_explicit: empty variable yields _EXPLICIT=0" {
+ MY_VAR=""
+ mark_explicit MY_VAR
+ [ "$MY_VAR_EXPLICIT" = "0" ]
+}
+
+@test "mark_explicit: non-empty variable yields _EXPLICIT=1" {
+ MY_VAR="something"
+ mark_explicit MY_VAR
+ [ "$MY_VAR_EXPLICIT" = "1" ]
+}
+
+@test "mark_explicit: zero-string non-empty is _EXPLICIT=1" {
+ MY_VAR="0"
+ mark_explicit MY_VAR
+ [ "$MY_VAR_EXPLICIT" = "1" ]
+}
diff --git a/tools/test/unit/normalize_path.bats
b/tools/test/unit/normalize_path.bats
new file mode 100644
index 00000000..96ecd578
--- /dev/null
+++ b/tools/test/unit/normalize_path.bats
@@ -0,0 +1,64 @@
+#!/usr/bin/env bats
+
+setup() {
+ load '../helpers/load'
+ load_install_sh
+ reset_install_sh_state
+}
+
+@test "normalize_path: empty input yields empty string" {
+ run normalize_path ""
+ [ "$status" -eq 0 ]
+ [ "$output" = "" ]
+}
+
+@test "normalize_path: tilde expands to \$HOME" {
+ HOME="/home/u" run normalize_path "~/foo"
+ [ "$status" -eq 0 ]
+ [ "$output" = "/home/u/foo" ]
+}
+
+@test "normalize_path: relative path resolves against PWD" {
+ cd "$BATS_TEST_TMPDIR"
+ run normalize_path "bar"
+ [ "$status" -eq 0 ]
+ [ "$output" = "$BATS_TEST_TMPDIR/bar" ]
+}
+
+@test "normalize_path: dot relative is collapsed to current dir" {
+ cd "$BATS_TEST_TMPDIR"
+ run normalize_path "."
+ [ "$status" -eq 0 ]
+ [ "$output" = "$BATS_TEST_TMPDIR" ]
+}
+
+@test "normalize_path: trailing slash is stripped" {
+ run normalize_path "/x/y/"
+ [ "$status" -eq 0 ]
+ [ "$output" = "/x/y" ]
+}
+
+@test "normalize_path: multiple trailing slashes are stripped" {
+ run normalize_path "/x/y///"
+ [ "$status" -eq 0 ]
+ [ "$output" = "/x/y" ]
+}
+
+@test "normalize_path: consecutive slashes are collapsed" {
+ run normalize_path "/x//y///z"
+ [ "$status" -eq 0 ]
+ [ "$output" = "/x/y/z" ]
+}
+
+@test "normalize_path: root '/' is preserved" {
+ run normalize_path "/"
+ [ "$status" -eq 0 ]
+ [ "$output" = "/" ]
+}
+
+@test "normalize_path: combined tilde + dot + trailing slash" {
+ HOME=/h
+ run normalize_path "~/a/./b/"
+ [ "$status" -eq 0 ]
+ [ "$output" = "/h/a/b" ]
+}
diff --git a/tools/test/unit/parse_args.bats b/tools/test/unit/parse_args.bats
new file mode 100644
index 00000000..76084472
--- /dev/null
+++ b/tools/test/unit/parse_args.bats
@@ -0,0 +1,82 @@
+#!/usr/bin/env bats
+
+setup() {
+ load '../helpers/load'
+ load_install_sh
+ reset_install_sh_state
+}
+
+@test "parse_args: --non-interactive sets NO_PROMPT=1" {
+ parse_args --non-interactive
+ [ "$NO_PROMPT" = "1" ]
+}
+
+@test "parse_args: --install-flink sets INSTALL_FLINK=Yes" {
+ parse_args --install-flink
+ [ "$INSTALL_FLINK" = "Yes" ]
+}
+
+@test "parse_args: --enable-pyflink sets ENABLE_PYFLINK=Yes" {
+ parse_args --enable-pyflink
+ [ "$ENABLE_PYFLINK" = "Yes" ]
+}
+
+@test "parse_args: --enable-pyFlink alias is honored" {
+ parse_args --enable-pyFlink
+ [ "$ENABLE_PYFLINK" = "Yes" ]
+}
+
+@test "parse_args: --verbose sets VERBOSE=1" {
+ parse_args --verbose
+ [ "$VERBOSE" = "1" ]
+}
+
+@test "parse_args: --dry-run sets DRY_RUN=1" {
+ parse_args --dry-run
+ [ "$DRY_RUN" = "1" ]
+}
+
+@test "parse_args: --verify sets VERIFY_INSTALL=1" {
+ parse_args --verify
+ [ "$VERIFY_INSTALL" = "1" ]
+}
+
+@test "parse_args: --python <path> sets PYTHON_BIN" {
+ parse_args --python /opt/py/bin/python3
+ [ "$PYTHON_BIN" = "/opt/py/bin/python3" ]
+}
+
+@test "parse_args: --python=<path> sets PYTHON_BIN" {
+ parse_args --python=/usr/local/bin/python3.11
+ [ "$PYTHON_BIN" = "/usr/local/bin/python3.11" ]
+}
+
+@test "parse_args: --python without arg dies" {
+ run parse_args --python
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"--python requires a path argument"* ]]
+}
+
+@test "parse_args: --help sets HELP=1" {
+ parse_args --help
+ [ "$HELP" = "1" ]
+}
+
+@test "parse_args: -h sets HELP=1" {
+ parse_args -h
+ [ "$HELP" = "1" ]
+}
+
+@test "parse_args: unknown flag warns but does not die" {
+ run parse_args --no-such-flag
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Unknown option: --no-such-flag"* ]]
+}
+
+@test "parse_args: combined flags all apply" {
+ parse_args --non-interactive --install-flink --enable-pyflink --verify
+ [ "$NO_PROMPT" = "1" ]
+ [ "$INSTALL_FLINK" = "Yes" ]
+ [ "$ENABLE_PYFLINK" = "Yes" ]
+ [ "$VERIFY_INSTALL" = "1" ]
+}
diff --git a/tools/test/unit/platform_detect.bats
b/tools/test/unit/platform_detect.bats
new file mode 100644
index 00000000..c1340080
--- /dev/null
+++ b/tools/test/unit/platform_detect.bats
@@ -0,0 +1,98 @@
+#!/usr/bin/env bats
+
+setup() {
+ load '../helpers/load'
+ load '../helpers/shim'
+ load_install_sh
+ reset_install_sh_state
+ shim_setup
+}
+
+# Helper: install a fake `uname` that emits a chosen string for -s and -m.
+fake_uname() {
+ local sysname="$1" machine="$2"
+ shim_bin_script uname "
+case \"\$1\" in
+ -s) echo '$sysname' ;;
+ -m) echo '$machine' ;;
+ *) echo 'fake' ;;
+esac
+"
+}
+
+@test "gum_detect_os: Darwin" {
+ fake_uname Darwin x86_64
+ [ "$(gum_detect_os)" = "Darwin" ]
+}
+
+@test "gum_detect_os: Linux" {
+ fake_uname Linux x86_64
+ [ "$(gum_detect_os)" = "Linux" ]
+}
+
+@test "gum_detect_os: unknown kernel" {
+ fake_uname FreeBSD x86_64
+ [ "$(gum_detect_os)" = "unsupported" ]
+}
+
+@test "gum_detect_arch: x86_64 stays x86_64" {
+ fake_uname Linux x86_64
+ [ "$(gum_detect_arch)" = "x86_64" ]
+}
+
+@test "gum_detect_arch: amd64 maps to x86_64" {
+ fake_uname Linux amd64
+ [ "$(gum_detect_arch)" = "x86_64" ]
+}
+
+@test "gum_detect_arch: arm64 stays arm64" {
+ fake_uname Darwin arm64
+ [ "$(gum_detect_arch)" = "arm64" ]
+}
+
+@test "gum_detect_arch: aarch64 maps to arm64" {
+ fake_uname Linux aarch64
+ [ "$(gum_detect_arch)" = "arm64" ]
+}
+
+@test "gum_detect_arch: i686 maps to i386" {
+ fake_uname Linux i686
+ [ "$(gum_detect_arch)" = "i386" ]
+}
+
+@test "gum_detect_arch: armv7l maps to armv7" {
+ fake_uname Linux armv7l
+ [ "$(gum_detect_arch)" = "armv7" ]
+}
+
+@test "gum_detect_arch: unknown machine is unknown" {
+ fake_uname Linux riscv64
+ [ "$(gum_detect_arch)" = "unknown" ]
+}
+
+@test "detect_os_or_die: macOS via OSTYPE" {
+ OSTYPE="darwin23"
+ detect_os_or_die
+ [ "$OS" = "macos" ]
+}
+
+@test "detect_os_or_die: linux via OSTYPE" {
+ OSTYPE="linux-gnu"
+ detect_os_or_die
+ [ "$OS" = "linux" ]
+}
+
+@test "detect_os_or_die: WSL via WSL_DISTRO_NAME" {
+ OSTYPE="something-unknown"
+ WSL_DISTRO_NAME="Ubuntu"
+ detect_os_or_die
+ [ "$OS" = "linux" ]
+}
+
+@test "detect_os_or_die: unsupported OSTYPE dies" {
+ OSTYPE="cygwin"
+ unset WSL_DISTRO_NAME
+ run detect_os_or_die
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"Unsupported operating system"* ]]
+}
diff --git a/tools/test/unit/shim_self_test.bats
b/tools/test/unit/shim_self_test.bats
new file mode 100644
index 00000000..04def76a
--- /dev/null
+++ b/tools/test/unit/shim_self_test.bats
@@ -0,0 +1,50 @@
+#!/usr/bin/env bats
+
+bats_require_minimum_version 1.5.0
+
+setup() {
+ load '../helpers/load'
+ load '../helpers/shim'
+ load_install_sh
+ reset_install_sh_state
+ shim_setup
+}
+
+@test "shim_bin records argv for a single call" {
+ shim_bin fake_tool
+ fake_tool --foo bar baz
+ [ "$(shim_call_count fake_tool)" = "1" ]
+ run shim_calls fake_tool
+ [ "$output" = "--foo bar baz" ]
+}
+
+@test "shim_bin can simulate non-zero exit" {
+ shim_bin fake_tool 7
+ run fake_tool
+ [ "$status" -eq 7 ]
+}
+
+@test "shim_bin_script can write output files" {
+ shim_bin_script fake_tar 'touch "$2/marker"'
+ mkdir -p "$BATS_TEST_TMPDIR/out"
+ fake_tar -x "$BATS_TEST_TMPDIR/out"
+ [ -f "$BATS_TEST_TMPDIR/out/marker" ]
+}
+
+@test "PATH shim is preferred over real binary" {
+ shim_bin curl
+ run command -v curl
+ [[ "$output" == "$BATS_TEST_TMPDIR/bin/curl" ]]
+}
+
+@test "shim_bin_missing makes command -v report missing" {
+ shim_bin_missing tool_x
+ run command -v tool_x
+ [ "$status" -ne 0 ]
+}
+
+@test "shim_bin_missing makes direct invocation exit 127" {
+ shim_bin_missing tool_y
+ run -127 tool_y --some arg
+ [ "$status" -eq 127 ]
+}
diff --git a/tools/test/unit/show_install_plan.bats
b/tools/test/unit/show_install_plan.bats
new file mode 100644
index 00000000..0a304a2f
--- /dev/null
+++ b/tools/test/unit/show_install_plan.bats
@@ -0,0 +1,74 @@
+#!/usr/bin/env bats
+
+setup() {
+ load '../helpers/load'
+ load_install_sh
+ reset_install_sh_state
+ OS="linux"
+}
+
+@test "show_install_plan: Environment section always present" {
+ INSTALL_FLINK=Yes
+ INSTALL_DIR="/opt/flink"
+ FLINK_HOME="/opt/flink/flink-2.2.0"
+ run show_install_plan
+ [ "$status" -eq 0 ]
+ case "$output" in *"Environment (read-only)"*) ;; *) false ;; esac
+ case "$output" in *"OS:"*) ;; *) false ;; esac
+ case "$output" in *"Java:"*) ;; *) false ;; esac
+ case "$output" in *"JAVA_HOME:"*) ;; *) false ;; esac
+ case "$output" in *"Python:"*) ;; *) false ;; esac
+}
+
+@test "show_install_plan: INSTALL_FLINK=Yes shows version + install directory,
not FLINK_HOME explicit" {
+ INSTALL_FLINK=Yes
+ INSTALL_DIR="/opt/flink"
+ FLINK_VERSION="2.2.0"
+ FLINK_HOME="/opt/flink/flink-2.2.0"
+ run show_install_plan
+ [ "$status" -eq 0 ]
+ case "$output" in *"Flink version"*) ;; *) false ;; esac
+ case "$output" in *"Install directory"*) ;; *) false ;; esac
+}
+
+@test "show_install_plan: INSTALL_FLINK=No hides Install directory, shows
FLINK_HOME with version" {
+ INSTALL_FLINK=No
+ INSTALL_DIR="/should/not/show"
+ FLINK_VERSION="2.1.1"
+ FLINK_HOME="/usr/local/flink"
+ run show_install_plan
+ [ "$status" -eq 0 ]
+ case "$output" in *"FLINK_HOME"*) ;; *) false ;; esac
+ case "$output" in *"v2.1.1"*) ;; *) false ;; esac
+ case "$output" in *"Install directory"*) false ;; *) ;; esac
+ case "$output" in *"/should/not/show"*) false ;; *) ;; esac
+}
+
+@test "show_install_plan: JAVA_HOME unset shows hint" {
+ INSTALL_FLINK=Yes
+ INSTALL_DIR="/opt/flink"
+ FLINK_HOME="/opt/flink/flink-2.2.0"
+ unset JAVA_HOME
+ run show_install_plan
+ [ "$status" -eq 0 ]
+ case "$output" in *"<not set, Flink will auto-detect>"*) ;; *) false ;;
esac
+}
+
+@test "show_install_plan: Flink Agents version line is present" {
+ INSTALL_FLINK=Yes
+ INSTALL_DIR="/opt/flink"
+ FLINK_HOME="/opt/flink/flink-2.2.0"
+ FLINK_AGENTS_VERSION="0.2.1"
+ run show_install_plan
+ [ "$status" -eq 0 ]
+ case "$output" in *"Flink Agents version"*"0.2.1"*) ;; *) false ;; esac
+}
+
+@test "show_install_plan: no redundant 'Install flink-agents JARs' row" {
+ INSTALL_FLINK=Yes
+ INSTALL_DIR="/opt/flink"
+ FLINK_HOME="/opt/flink/flink-2.2.0"
+ run show_install_plan
+ [ "$status" -eq 0 ]
+ case "$output" in *"Install flink-agents JARs"*) false ;; *) ;; esac
+}
diff --git a/tools/test/unit/smoke.bats b/tools/test/unit/smoke.bats
new file mode 100644
index 00000000..08ced7ee
--- /dev/null
+++ b/tools/test/unit/smoke.bats
@@ -0,0 +1,18 @@
+#!/usr/bin/env bats
+
+setup() {
+ load '../helpers/load'
+ load_install_sh
+ reset_install_sh_state
+}
+
+@test "install.sh sources without executing main" {
+ [ "$(type -t parse_args)" = "function" ]
+ [ "$(type -t download_file)" = "function" ]
+}
+
+@test "reset_install_sh_state restores defaults" {
+ NO_PROMPT=1
+ reset_install_sh_state
+ [ "$NO_PROMPT" = "0" ]
+}
diff --git a/tools/test/unit/ui_helpers.bats b/tools/test/unit/ui_helpers.bats
new file mode 100644
index 00000000..61ad31d0
--- /dev/null
+++ b/tools/test/unit/ui_helpers.bats
@@ -0,0 +1,54 @@
+#!/usr/bin/env bats
+
+setup() {
+ load '../helpers/load'
+ load_install_sh
+ reset_install_sh_state
+ # Force the non-gum fallback branch.
+ GUM=""
+}
+
+@test "ui_info: prints the message in fallback branch" {
+ run ui_info "hello world"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"hello world"* ]]
+}
+
+@test "ui_warn: prints the message in fallback branch" {
+ run ui_warn "be careful"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"be careful"* ]]
+}
+
+@test "ui_success: prints the message in fallback branch" {
+ run ui_success "all good"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"all good"* ]]
+}
+
+@test "ui_error: prints the message in fallback branch" {
+ run ui_error "uh oh"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"uh oh"* ]]
+}
+
+@test "ui_kv: prints key and value" {
+ run ui_kv "Flink version" "2.2.0"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"Flink version"* ]]
+ [[ "$output" == *"2.2.0"* ]]
+}
+
+@test "ui_stage: increments stage counter and prints title" {
+ INSTALL_STAGE_CURRENT=0
+ run ui_stage "First thing"
+ [ "$status" -eq 0 ]
+ [[ "$output" == *"First thing"* ]]
+ [[ "$output" == *"[1/${INSTALL_STAGE_TOTAL}]"* ]]
+}
+
+@test "die: prints message and exits non-zero" {
+ run die "fatal boom"
+ [ "$status" -ne 0 ]
+ [[ "$output" == *"fatal boom"* ]]
+}
diff --git a/tools/test/unit/validate_python_bin.bats
b/tools/test/unit/validate_python_bin.bats
new file mode 100644
index 00000000..87998bd1
--- /dev/null
+++ b/tools/test/unit/validate_python_bin.bats
@@ -0,0 +1,73 @@
+#!/usr/bin/env bats
+
+setup() {
+ load '../helpers/load'
+ load '../helpers/shim'
+ load_install_sh
+ reset_install_sh_state
+ shim_setup
+}
+
+# Helper: write a fake python that reports a chosen version.
+fake_python() {
+ local ver="$1"
+ shim_bin_script fake_py "
+case \"\$*\" in
+ *'sys.version_info.major'*)
+ echo '$ver'
+ ;;
+esac
+"
+}
+
+@test "validate_python_bin: empty path is rejected" {
+ run validate_python_bin ""
+ [ "$status" -ne 0 ]
+}
+
+@test "validate_python_bin: missing binary is rejected" {
+ run validate_python_bin /no/such/bin
+ [ "$status" -ne 0 ]
+}
+
+@test "validate_python_bin: Python 3.10 is accepted" {
+ fake_python "3.10"
+ run validate_python_bin fake_py
+ [ "$status" -eq 0 ]
+}
+
+@test "validate_python_bin: Python 3.11 is accepted" {
+ fake_python "3.11"
+ run validate_python_bin fake_py
+ [ "$status" -eq 0 ]
+}
+
+@test "validate_python_bin: Python 3.9 is rejected" {
+ fake_python "3.9"
+ run validate_python_bin fake_py
+ [ "$status" -ne 0 ]
+}
+
+@test "validate_python_bin: Python 3.12 is rejected" {
+ fake_python "3.12"
+ run validate_python_bin fake_py
+ [ "$status" -ne 0 ]
+}
+
+@test "validate_python_bin: Python 3.13 is rejected" {
+ fake_python "3.13"
+ run validate_python_bin fake_py
+ [ "$status" -ne 0 ]
+}
+
+@test "validate_python_bin: Python 2.7 is rejected" {
+ fake_python "2.7"
+ run validate_python_bin fake_py
+ [ "$status" -ne 0 ]
+}
+
+@test "validate_python_bin: binary that prints nothing is rejected" {
+ shim_bin_script fake_py 'exit 0'
+ run validate_python_bin fake_py
+ [ "$status" -ne 0 ]
+}
diff --git a/tools/test/unit/validate_venv_dir.bats
b/tools/test/unit/validate_venv_dir.bats
new file mode 100644
index 00000000..b41dfd15
--- /dev/null
+++ b/tools/test/unit/validate_venv_dir.bats
@@ -0,0 +1,83 @@
+#!/usr/bin/env bats
+
+# validate_venv_dir classifies a candidate VENV_DIR path so the caller
+# can either accept it (new / empty / real venv) or re-prompt the user
+# (non-empty foreign directory or file).
+#
+# Echoes one of: new | empty | venv | nonempty | file
+# Exit code 0 for new/empty/venv (caller proceeds), 1 for nonempty/file
+# (caller re-prompts).
+
+setup() {
+ load '../helpers/load'
+ load_install_sh
+ reset_install_sh_state
+}
+
+@test "validate_venv_dir: path that does not exist → 'new'" {
+ local p="$BATS_TEST_TMPDIR/will-be-created"
+ run validate_venv_dir "$p"
+ [ "$status" -eq 0 ]
+ [ "$output" = "new" ]
+}
+
+@test "validate_venv_dir: empty directory → 'empty'" {
+ local p="$BATS_TEST_TMPDIR/empty"
+ mkdir -p "$p"
+ run validate_venv_dir "$p"
+ [ "$status" -eq 0 ]
+ [ "$output" = "empty" ]
+}
+
+@test "validate_venv_dir: directory with pyvenv.cfg → 'venv'" {
+ local p="$BATS_TEST_TMPDIR/avenv"
+ mkdir -p "$p/bin"
+ : > "$p/pyvenv.cfg"
+ : > "$p/bin/activate"
+ run validate_venv_dir "$p"
+ [ "$status" -eq 0 ]
+ [ "$output" = "venv" ]
+}
+
+@test "validate_venv_dir: non-empty foreign directory → 'nonempty' (rc=1)" {
+ local p="$BATS_TEST_TMPDIR/foreign"
+ mkdir -p "$p"
+ : > "$p/some-unrelated-file"
+ : > "$p/README.md"
+ run validate_venv_dir "$p"
+ [ "$status" -eq 1 ]
+ [ "$output" = "nonempty" ]
+}
+
+@test "validate_venv_dir: path is a regular file → 'file' (rc=1)" {
+ local p="$BATS_TEST_TMPDIR/just-a-file"
+ : > "$p"
+ run validate_venv_dir "$p"
+ [ "$status" -eq 1 ]
+ [ "$output" = "file" ]
+}
+
+@test "validate_venv_dir: directory with only a hidden file is still
'nonempty'" {
+ local p="$BATS_TEST_TMPDIR/dot"
+ mkdir -p "$p"
+ : > "$p/.hiddenfile"
+ run validate_venv_dir "$p"
+ [ "$status" -eq 1 ]
+ [ "$output" = "nonempty" ]
+}
+
+@test "validate_venv_dir: bin/activate without pyvenv.cfg is still 'nonempty'"
{
+ # A user could have a random project with a bin/activate file. Treat
+ # only the strict pyvenv.cfg marker as a real venv.
+ local p="$BATS_TEST_TMPDIR/halfbaked"
+ mkdir -p "$p/bin"
+ : > "$p/bin/activate"
+ run validate_venv_dir "$p"
+ [ "$status" -eq 1 ]
+ [ "$output" = "nonempty" ]
+}
+
+@test "validate_venv_dir: empty argument → 'file' (rc=1) (treated as invalid)"
{
+ run validate_venv_dir ""
+ [ "$status" -eq 1 ]
+}