#!/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}" HELP=0 PYFLINK_ACTUALLY_ENABLED=0 print_usage() { cat </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 --python Path to a Python3 interpreter (overrides PATH lookup) --flink-version Apache Flink version (e.g. 2.2.0); overrides the interactive picker --flink-agents-version 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 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-.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: # .(.)?(-)? # 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:-}") 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 ` 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, " (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--.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 ." 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" "" fi local python_summary="" 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 } verify_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 "$VENV_DIR/bin/python3" -c "import flink_agents; print('flink-agents ready')" 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_next_step(){ 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() { 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 ;; --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 if [[ "${NO_PROMPT:-0}" != "1" ]] && [[ ! -t 0 ]] && { : /dev/null; then exec