704 lines
20 KiB
Bash
704 lines
20 KiB
Bash
#!/bin/ash
|
||
# shellcheck shell=ash
|
||
|
||
# Erwartet vorhandene msg_* und optional $STD aus deinem Framework.
|
||
|
||
# Fallbacks, wenn core.func nicht geladen wurde (Alpine/ash-safe)
|
||
if ! command -v msg_info >/dev/null 2>&1; then
|
||
msg_info() { echo "[INFO] $*"; }
|
||
fi
|
||
if ! command -v msg_ok >/dev/null 2>&1; then
|
||
msg_ok() { echo "[OK] $*"; }
|
||
fi
|
||
if ! command -v msg_warn >/dev/null 2>&1; then
|
||
msg_warn() { echo "[WARN] $*"; }
|
||
fi
|
||
if ! command -v msg_error >/dev/null 2>&1; then
|
||
msg_error() { echo "[ERROR] $*" >&2; }
|
||
fi
|
||
|
||
# ------------------------------
|
||
# helpers
|
||
# ------------------------------
|
||
lower() { printf '%s' "$1" | tr '[:upper:]' '[:lower:]'; }
|
||
has() { command -v "$1" >/dev/null 2>&1; }
|
||
|
||
# tools.func compatibility helpers (Alpine-safe)
|
||
cache_installed_version() {
|
||
local app="$1" version="$2"
|
||
mkdir -p /var/cache/app-versions
|
||
echo "$version" >"/var/cache/app-versions/${app}_version.txt"
|
||
}
|
||
|
||
get_cached_version() {
|
||
local app="$1"
|
||
mkdir -p /var/cache/app-versions
|
||
if [ -f "/var/cache/app-versions/${app}_version.txt" ]; then
|
||
cat "/var/cache/app-versions/${app}_version.txt"
|
||
return 0
|
||
fi
|
||
return 0
|
||
}
|
||
|
||
version_gt() {
|
||
# returns 0 if $1 > $2
|
||
# BusyBox-safe version compare
|
||
awk -v a="$1" -v b="$2" '
|
||
function splitver(v, arr) { n=split(v, arr, /\./); return n }
|
||
BEGIN {
|
||
na=splitver(a, A); nb=splitver(b, B);
|
||
n=(na>nb?na:nb);
|
||
for (i=1;i<=n;i++) {
|
||
va=(A[i]==""?0:A[i]); vb=(B[i]==""?0:B[i]);
|
||
if (va+0 > vb+0) exit 0;
|
||
if (va+0 < vb+0) exit 1;
|
||
}
|
||
exit 1;
|
||
}'
|
||
}
|
||
|
||
get_system_arch() {
|
||
local arch
|
||
arch=$(uname -m 2>/dev/null || echo "")
|
||
[ "$arch" = "x86_64" ] && arch="amd64"
|
||
[ "$arch" = "aarch64" ] && arch="arm64"
|
||
echo "$arch"
|
||
}
|
||
|
||
create_temp_dir() {
|
||
mktemp -d
|
||
}
|
||
|
||
get_os_info() {
|
||
local field="${1:-all}"
|
||
[ -z "${_OS_ID:-}" ] && _OS_ID=$(awk -F= '/^ID=/{gsub(/"/,"",$2); print $2}' /etc/os-release 2>/dev/null)
|
||
[ -z "${_OS_CODENAME:-}" ] && _OS_CODENAME=$(awk -F= '/^VERSION_CODENAME=/{gsub(/"/,"",$2); print $2}' /etc/os-release 2>/dev/null)
|
||
[ -z "${_OS_VERSION:-}" ] && _OS_VERSION=$(awk -F= '/^VERSION_ID=/{gsub(/"/,"",$2); print $2}' /etc/os-release 2>/dev/null)
|
||
case "$field" in
|
||
id) echo "$_OS_ID" ;;
|
||
codename) echo "$_OS_CODENAME" ;;
|
||
version | version_id) echo "$_OS_VERSION" ;;
|
||
all) echo "ID=$_OS_ID CODENAME=$_OS_CODENAME VERSION=$_OS_VERSION" ;;
|
||
*) echo "$_OS_ID" ;;
|
||
esac
|
||
}
|
||
|
||
is_alpine() { [ "$(get_os_info id)" = "alpine" ]; }
|
||
|
||
get_os_version_major() {
|
||
local v
|
||
v=$(get_os_info version)
|
||
echo "${v%%.*}"
|
||
}
|
||
|
||
ensure_dependencies() {
|
||
need_tool "$@"
|
||
}
|
||
|
||
download_file() {
|
||
local url="$1" output="$2" max_retries="${3:-3}" show_progress="${4:-false}"
|
||
local i=1 curl_opts="-fsSL"
|
||
[ "$show_progress" = "true" ] && curl_opts="-fL#"
|
||
while [ $i -le "$max_retries" ]; do
|
||
if curl $curl_opts -o "$output" "$url"; then
|
||
return 0
|
||
fi
|
||
i=$((i + 1))
|
||
[ $i -le "$max_retries" ] && sleep 2
|
||
done
|
||
msg_error "Failed to download: $url"
|
||
return 1
|
||
}
|
||
|
||
github_api_call() {
|
||
local url="$1" output_file="${2:-/dev/stdout}"
|
||
local max_retries=3 retry_delay=2 attempt=1
|
||
local header=""
|
||
[ -n "${GITHUB_TOKEN:-}" ] && header="-H Authorization:Bearer\ ${GITHUB_TOKEN}"
|
||
while [ $attempt -le $max_retries ]; do
|
||
http_code=$(curl -fsSL -w "%{http_code}" -o "$output_file" \
|
||
-H "Accept: application/vnd.github+json" \
|
||
-H "X-GitHub-Api-Version: 2022-11-28" \
|
||
$header "$url" 2>/dev/null || echo 000)
|
||
case "$http_code" in
|
||
200) return 0 ;;
|
||
403) [ $attempt -lt $max_retries ] && sleep "$retry_delay" || {
|
||
msg_error "GitHub API rate limit exceeded"
|
||
return 1
|
||
} ;;
|
||
404)
|
||
msg_error "GitHub API endpoint not found: $url"
|
||
return 1
|
||
;;
|
||
*) [ $attempt -lt $max_retries ] && sleep "$retry_delay" || {
|
||
msg_error "GitHub API call failed with HTTP $http_code"
|
||
return 1
|
||
} ;;
|
||
esac
|
||
retry_delay=$((retry_delay * 2))
|
||
attempt=$((attempt + 1))
|
||
done
|
||
return 1
|
||
}
|
||
|
||
extract_version_from_json() {
|
||
local json="$1" field="${2:-tag_name}" strip_v="${3:-true}" version
|
||
need_tool jq || return 1
|
||
version=$(printf '%s' "$json" | jq -r ".${field} // empty")
|
||
[ -z "$version" ] && return 1
|
||
[ "$strip_v" = "true" ] && printf '%s' "${version#v}" || printf '%s' "$version"
|
||
}
|
||
|
||
get_latest_github_release() {
|
||
local repo="$1" strip_v="${2:-true}" tmp
|
||
tmp=$(mktemp) || return 1
|
||
github_api_call "https://api.github.com/repos/${repo}/releases/latest" "$tmp" || {
|
||
rm -f "$tmp"
|
||
return 1
|
||
}
|
||
extract_version_from_json "$(cat "$tmp")" "tag_name" "$strip_v"
|
||
rc=$?
|
||
rm -f "$tmp"
|
||
return $rc
|
||
}
|
||
|
||
need_tool() {
|
||
# usage: need_tool curl jq unzip ...
|
||
# setup missing tools via apk
|
||
local missing=0 t
|
||
for t in "$@"; do
|
||
if ! has "$t"; then missing=1; fi
|
||
done
|
||
if [ "$missing" -eq 1 ]; then
|
||
msg_info "Installing tools: $*"
|
||
apk add --no-cache "$@" >/dev/null 2>&1 || {
|
||
msg_error "apk add failed for: $*"
|
||
return 1
|
||
}
|
||
msg_ok "Tools ready: $*"
|
||
fi
|
||
}
|
||
|
||
net_resolves() {
|
||
# better handling for missing getent on Alpine
|
||
# usage: net_resolves api.github.com
|
||
local host="$1"
|
||
ping -c1 -W1 "$host" >/dev/null 2>&1 || nslookup "$host" >/dev/null 2>&1
|
||
}
|
||
|
||
ensure_usr_local_bin_persist() {
|
||
local PROFILE_FILE="/etc/profile.d/10-localbin.sh"
|
||
if [ ! -f "$PROFILE_FILE" ]; then
|
||
echo 'case ":$PATH:" in *:/usr/local/bin:*) ;; *) export PATH="/usr/local/bin:$PATH";; esac' >"$PROFILE_FILE"
|
||
chmod +x "$PROFILE_FILE"
|
||
fi
|
||
}
|
||
|
||
download_with_progress() {
|
||
# $1 url, $2 dest
|
||
local url="$1" out="$2" cl
|
||
need_tool curl pv || return 1
|
||
cl=$(curl -fsSLI "$url" 2>/dev/null | awk 'tolower($0) ~ /^content-length:/ {print $2}' | tr -d '\r')
|
||
if [ -n "$cl" ]; then
|
||
curl -fsSL "$url" | pv -s "$cl" >"$out" || {
|
||
msg_error "Download failed: $url"
|
||
return 1
|
||
}
|
||
else
|
||
curl -fL# -o "$out" "$url" || {
|
||
msg_error "Download failed: $url"
|
||
return 1
|
||
}
|
||
fi
|
||
}
|
||
|
||
# ------------------------------
|
||
# GitHub: check Release
|
||
# ------------------------------
|
||
check_for_gh_release() {
|
||
# app, repo, [pinned]
|
||
local app="$1" source="$2" pinned="${3:-}"
|
||
local app_lc
|
||
app_lc="$(lower "$app" | tr -d ' ')"
|
||
local current_file="$HOME/.${app_lc}"
|
||
local current="" release tag
|
||
|
||
msg_info "Check for update: $app"
|
||
|
||
net_resolves api.github.com || {
|
||
msg_error "DNS/network error: api.github.com"
|
||
return 1
|
||
}
|
||
need_tool curl jq || return 1
|
||
|
||
github_api_call "https://api.github.com/repos/${source}/releases/latest" "/tmp/${app_lc}-release.json" || {
|
||
msg_error "Unable to fetch latest tag for $app"
|
||
return 1
|
||
}
|
||
tag=$(cat "/tmp/${app_lc}-release.json" | jq -r '.tag_name // empty')
|
||
rm -f "/tmp/${app_lc}-release.json"
|
||
[ -z "$tag" ] && {
|
||
msg_error "Unable to fetch latest tag for $app"
|
||
return 1
|
||
}
|
||
release="${tag#v}"
|
||
|
||
[ -f "$current_file" ] && current="$(cat "$current_file")"
|
||
|
||
if [ -n "$pinned" ]; then
|
||
if [ "$pinned" = "$release" ]; then
|
||
msg_ok "$app pinned to v$pinned (no update)"
|
||
return 1
|
||
fi
|
||
if [ "$current" = "$pinned" ]; then
|
||
msg_ok "$app pinned v$pinned installed (upstream v$release)"
|
||
return 1
|
||
fi
|
||
msg_info "$app pinned v$pinned (upstream v$release) → update/downgrade"
|
||
CHECK_UPDATE_RELEASE="$pinned"
|
||
return 0
|
||
fi
|
||
|
||
if [ "$release" != "$current" ] || [ ! -f "$current_file" ]; then
|
||
CHECK_UPDATE_RELEASE="$release"
|
||
msg_info "New release available: v$release (current: v${current:-none})"
|
||
return 0
|
||
fi
|
||
|
||
msg_ok "$app is up to date (v$release)"
|
||
return 1
|
||
}
|
||
|
||
# ------------------------------
|
||
# GitHub: get Release & deployen (Alpine)
|
||
# modes: tarball | prebuild | singlefile
|
||
# ------------------------------
|
||
fetch_and_deploy_gh_release() {
|
||
# $1 app, $2 repo, [$3 mode], [$4 version], [$5 target], [$6 asset_pattern
|
||
local app="$1" repo="$2" mode="${3:-tarball}" version="${4:-latest}" target="${5:-/opt/$1}" pattern="${6:-}"
|
||
local app_lc
|
||
app_lc="$(lower "$app" | tr -d ' ')"
|
||
local vfile="$HOME/.${app_lc}"
|
||
local json url filename tmpd unpack
|
||
|
||
net_resolves api.github.com || {
|
||
msg_error "DNS/network error"
|
||
return 1
|
||
}
|
||
need_tool curl jq tar || return 1
|
||
[ "$mode" = "prebuild" ] || [ "$mode" = "singlefile" ] && need_tool unzip >/dev/null 2>&1 || true
|
||
|
||
tmpd="$(mktemp -d)" || return 1
|
||
mkdir -p "$target"
|
||
|
||
# Release JSON (with token/rate-limit handling)
|
||
if [ "$version" = "latest" ]; then
|
||
github_api_call "https://api.github.com/repos/$repo/releases/latest" "$tmpd/release.json" || {
|
||
msg_error "GitHub API failed"
|
||
rm -rf "$tmpd"
|
||
return 1
|
||
}
|
||
else
|
||
github_api_call "https://api.github.com/repos/$repo/releases/tags/$version" "$tmpd/release.json" || {
|
||
msg_error "GitHub API failed"
|
||
rm -rf "$tmpd"
|
||
return 1
|
||
}
|
||
fi
|
||
json="$(cat "$tmpd/release.json")"
|
||
|
||
# correct Version
|
||
version="$(printf '%s' "$json" | jq -r '.tag_name // empty')"
|
||
version="${version#v}"
|
||
|
||
[ -z "$version" ] && {
|
||
msg_error "No tag in release json"
|
||
rm -rf "$tmpd"
|
||
return 1
|
||
}
|
||
|
||
case "$mode" in
|
||
tarball | source)
|
||
url="$(printf '%s' "$json" | jq -r '.tarball_url // empty')"
|
||
[ -z "$url" ] && url="https://github.com/$repo/archive/refs/tags/v$version.tar.gz"
|
||
filename="${app_lc}-${version}.tar.gz"
|
||
download_with_progress "$url" "$tmpd/$filename" || {
|
||
rm -rf "$tmpd"
|
||
return 1
|
||
}
|
||
tar -xzf "$tmpd/$filename" -C "$tmpd" || {
|
||
msg_error "tar extract failed"
|
||
rm -rf "$tmpd"
|
||
return 1
|
||
}
|
||
unpack="$(find "$tmpd" -mindepth 1 -maxdepth 1 -type d | head -n1)"
|
||
[ "${CLEAN_INSTALL:-0}" = "1" ] && rm -rf "${target:?}/"*
|
||
# copy content of unpack to target
|
||
(cd "$unpack" && tar -cf - .) | (cd "$target" && tar -xf -) || {
|
||
msg_error "copy failed"
|
||
rm -rf "$tmpd"
|
||
return 1
|
||
}
|
||
;;
|
||
binary)
|
||
[ -n "$pattern" ] || pattern="*.apk"
|
||
url="$(printf '%s' "$json" | jq -r '.assets[].browser_download_url' | awk -v p="$pattern" 'BEGIN{IGNORECASE=1} $0 ~ p {print; exit}')"
|
||
[ -z "$url" ] && {
|
||
msg_error "binary asset not found for pattern: $pattern"
|
||
rm -rf "$tmpd"
|
||
return 1
|
||
}
|
||
filename="${url##*/}"
|
||
download_with_progress "$url" "$tmpd/$filename" || {
|
||
rm -rf "$tmpd"
|
||
return 1
|
||
}
|
||
case "$filename" in
|
||
*.apk)
|
||
apk add --no-cache --allow-untrusted "$tmpd/$filename" >/dev/null 2>&1 || {
|
||
msg_error "apk install failed: $filename"
|
||
rm -rf "$tmpd"
|
||
return 1
|
||
}
|
||
;;
|
||
*)
|
||
msg_error "Unsupported binary asset on Alpine: $filename"
|
||
rm -rf "$tmpd"
|
||
return 1
|
||
;;
|
||
esac
|
||
;;
|
||
prebuild)
|
||
[ -n "$pattern" ] || {
|
||
msg_error "prebuild requires asset pattern"
|
||
rm -rf "$tmpd"
|
||
return 1
|
||
}
|
||
url="$(printf '%s' "$json" | jq -r '.assets[].browser_download_url' | awk -v p="$pattern" '
|
||
BEGIN{IGNORECASE=1}
|
||
$0 ~ p {print; exit}
|
||
')"
|
||
[ -z "$url" ] && {
|
||
msg_error "asset not found for pattern: $pattern"
|
||
rm -rf "$tmpd"
|
||
return 1
|
||
}
|
||
filename="${url##*/}"
|
||
download_with_progress "$url" "$tmpd/$filename" || {
|
||
rm -rf "$tmpd"
|
||
return 1
|
||
}
|
||
# unpack archive (Zip or tarball)
|
||
case "$filename" in
|
||
*.zip)
|
||
need_tool unzip || {
|
||
rm -rf "$tmpd"
|
||
return 1
|
||
}
|
||
mkdir -p "$tmpd/unp"
|
||
unzip -q "$tmpd/$filename" -d "$tmpd/unp"
|
||
;;
|
||
*.tar.gz | *.tgz | *.tar.xz | *.tar.zst | *.tar.bz2)
|
||
mkdir -p "$tmpd/unp"
|
||
tar -xf "$tmpd/$filename" -C "$tmpd/unp"
|
||
;;
|
||
*)
|
||
msg_error "unsupported archive: $filename"
|
||
rm -rf "$tmpd"
|
||
return 1
|
||
;;
|
||
esac
|
||
[ "${CLEAN_INSTALL:-0}" = "1" ] && rm -rf "${target:?}/"*
|
||
# top-level folder strippen
|
||
if [ "$(find "$tmpd/unp" -mindepth 1 -maxdepth 1 -type d | wc -l)" -eq 1 ] && [ -z "$(find "$tmpd/unp" -mindepth 1 -maxdepth 1 -type f | head -n1)" ]; then
|
||
unpack="$(find "$tmpd/unp" -mindepth 1 -maxdepth 1 -type d)"
|
||
(cd "$unpack" && tar -cf - .) | (cd "$target" && tar -xf -) || {
|
||
msg_error "copy failed"
|
||
rm -rf "$tmpd"
|
||
return 1
|
||
}
|
||
else
|
||
(cd "$tmpd/unp" && tar -cf - .) | (cd "$target" && tar -xf -) || {
|
||
msg_error "copy failed"
|
||
rm -rf "$tmpd"
|
||
return 1
|
||
}
|
||
fi
|
||
;;
|
||
singlefile)
|
||
[ -n "$pattern" ] || {
|
||
msg_error "singlefile requires asset pattern"
|
||
rm -rf "$tmpd"
|
||
return 1
|
||
}
|
||
url="$(printf '%s' "$json" | jq -r '.assets[].browser_download_url' | awk -v p="$pattern" '
|
||
BEGIN{IGNORECASE=1}
|
||
$0 ~ p {print; exit}
|
||
')"
|
||
[ -z "$url" ] && {
|
||
msg_error "asset not found for pattern: $pattern"
|
||
rm -rf "$tmpd"
|
||
return 1
|
||
}
|
||
filename="${url##*/}"
|
||
local target_file="$app"
|
||
[ "${USE_ORIGINAL_FILENAME:-false}" = "true" ] && target_file="$filename"
|
||
download_with_progress "$url" "$target/$target_file" || {
|
||
rm -rf "$tmpd"
|
||
return 1
|
||
}
|
||
chmod +x "$target/$target_file"
|
||
;;
|
||
*)
|
||
msg_error "Unknown mode: $mode"
|
||
rm -rf "$tmpd"
|
||
return 1
|
||
;;
|
||
esac
|
||
|
||
echo "$version" >"$vfile"
|
||
ensure_usr_local_bin_persist
|
||
rm -rf "$tmpd"
|
||
msg_ok "Deployed $app ($version) → $target"
|
||
}
|
||
|
||
# tools.func compatibility alias
|
||
fetch_and_deploy_gh() {
|
||
fetch_and_deploy_gh_release "$@"
|
||
}
|
||
|
||
# ------------------------------
|
||
# yq (mikefarah) – Alpine
|
||
# ------------------------------
|
||
setup_yq() {
|
||
# prefer apk, unless FORCE_GH=1
|
||
if [ "${FORCE_GH:-0}" != "1" ] && apk info -e yq >/dev/null 2>&1; then
|
||
msg_info "Updating yq via apk"
|
||
apk add --no-cache --upgrade yq >/dev/null 2>&1 || true
|
||
msg_ok "yq ready ($(yq --version 2>/dev/null))"
|
||
return 0
|
||
fi
|
||
|
||
need_tool curl || return 1
|
||
local arch bin url tmp
|
||
case "$(uname -m)" in
|
||
x86_64) arch="amd64" ;;
|
||
aarch64) arch="arm64" ;;
|
||
*)
|
||
msg_error "Unsupported arch for yq: $(uname -m)"
|
||
return 1
|
||
;;
|
||
esac
|
||
url="https://github.com/mikefarah/yq/releases/latest/download/yq_linux_${arch}"
|
||
tmp="$(mktemp)"
|
||
download_with_progress "$url" "$tmp" || return 1
|
||
install -m 0755 "$tmp" /usr/local/bin/yq
|
||
rm -f "$tmp"
|
||
msg_ok "Setup yq ($(yq --version 2>/dev/null))"
|
||
}
|
||
|
||
# ------------------------------
|
||
# Adminer – Alpine
|
||
# ------------------------------
|
||
setup_adminer() {
|
||
need_tool curl || return 1
|
||
msg_info "Setup Adminer (Alpine)"
|
||
mkdir -p /var/www/localhost/htdocs/adminer
|
||
curl -fsSL https://github.com/vrana/adminer/releases/latest/download/adminer.php \
|
||
-o /var/www/localhost/htdocs/adminer/index.php || {
|
||
msg_error "Adminer download failed"
|
||
return 1
|
||
}
|
||
msg_ok "Adminer at /adminer (served by your webserver)"
|
||
}
|
||
|
||
# ------------------------------
|
||
# uv – Alpine (musl tarball)
|
||
# optional: PYTHON_VERSION="3.12"
|
||
# ------------------------------
|
||
setup_uv() {
|
||
need_tool curl tar || return 1
|
||
local UV_BIN="/usr/local/bin/uv"
|
||
local arch tarball url tmpd ver installed
|
||
|
||
case "$(uname -m)" in
|
||
x86_64) arch="x86_64-unknown-linux-musl" ;;
|
||
aarch64) arch="aarch64-unknown-linux-musl" ;;
|
||
*)
|
||
msg_error "Unsupported arch for uv: $(uname -m)"
|
||
return 1
|
||
;;
|
||
esac
|
||
|
||
ver="$(curl -fsSL https://api.github.com/repos/astral-sh/uv/releases/latest | jq -r '.tag_name' 2>/dev/null)"
|
||
ver="${ver#v}"
|
||
[ -z "$ver" ] && {
|
||
msg_error "uv: cannot determine latest version"
|
||
return 1
|
||
}
|
||
|
||
if has "$UV_BIN"; then
|
||
installed="$($UV_BIN -V 2>/dev/null | awk '{print $2}')"
|
||
[ "$installed" = "$ver" ] && {
|
||
msg_ok "uv $ver already installed"
|
||
return 0
|
||
}
|
||
msg_info "Updating uv $installed → $ver"
|
||
else
|
||
msg_info "Setup uv $ver"
|
||
fi
|
||
|
||
tmpd="$(mktemp -d)" || return 1
|
||
tarball="uv-${arch}.tar.gz"
|
||
url="https://github.com/astral-sh/uv/releases/download/v${ver}/${tarball}"
|
||
|
||
download_with_progress "$url" "$tmpd/uv.tar.gz" || {
|
||
rm -rf "$tmpd"
|
||
return 1
|
||
}
|
||
tar -xzf "$tmpd/uv.tar.gz" -C "$tmpd" || {
|
||
msg_error "uv: extract failed"
|
||
rm -rf "$tmpd"
|
||
return 1
|
||
}
|
||
|
||
# tar contains ./uv
|
||
if [ -x "$tmpd/uv" ]; then
|
||
install -m 0755 "$tmpd/uv" "$UV_BIN"
|
||
else
|
||
# fallback: in subfolder
|
||
install -m 0755 "$tmpd"/*/uv "$UV_BIN" 2>/dev/null || {
|
||
msg_error "uv binary not found in tar"
|
||
rm -rf "$tmpd"
|
||
return 1
|
||
}
|
||
fi
|
||
rm -rf "$tmpd"
|
||
ensure_usr_local_bin_persist
|
||
msg_ok "Setup uv $ver"
|
||
|
||
if [ -n "${PYTHON_VERSION:-}" ]; then
|
||
local match
|
||
match="$(uv python list --only-downloads 2>/dev/null | awk -v maj="$PYTHON_VERSION" '
|
||
$0 ~ "^cpython-"maj"\\." { print $0 }' | awk -F- '{print $2}' | sort -V | tail -n1)"
|
||
[ -z "$match" ] && {
|
||
msg_error "No matching Python for $PYTHON_VERSION"
|
||
return 1
|
||
}
|
||
if ! uv python list | grep -q "cpython-${match}-linux"; then
|
||
msg_info "Installing Python $match via uv"
|
||
uv python install "$match" || {
|
||
msg_error "uv python install failed"
|
||
return 1
|
||
}
|
||
msg_ok "Python $match installed (uv)"
|
||
fi
|
||
fi
|
||
}
|
||
|
||
# ------------------------------
|
||
# Java – Alpine (OpenJDK)
|
||
# JAVA_VERSION: 17|21 (Default 21)
|
||
# ------------------------------
|
||
setup_java() {
|
||
local JAVA_VERSION="${JAVA_VERSION:-21}" pkg
|
||
case "$JAVA_VERSION" in
|
||
17) pkg="openjdk17-jdk" ;;
|
||
21 | *) pkg="openjdk21-jdk" ;;
|
||
esac
|
||
msg_info "Setup Java (OpenJDK $JAVA_VERSION)"
|
||
apk add --no-cache "$pkg" >/dev/null 2>&1 || {
|
||
msg_error "apk add $pkg failed"
|
||
return 1
|
||
}
|
||
# set JAVA_HOME
|
||
local prof="/etc/profile.d/20-java.sh"
|
||
if [ ! -f "$prof" ]; then
|
||
echo 'export JAVA_HOME=$(dirname $(dirname $(readlink -f $(command -v java))))' >"$prof"
|
||
echo 'case ":$PATH:" in *:$JAVA_HOME/bin:*) ;; *) export PATH="$JAVA_HOME/bin:$PATH";; esac' >>"$prof"
|
||
chmod +x "$prof"
|
||
fi
|
||
msg_ok "Java ready: $(java -version 2>&1 | head -n1)"
|
||
}
|
||
|
||
# ------------------------------
|
||
# Go – Alpine (apk prefers, else tarball)
|
||
# ------------------------------
|
||
setup_go() {
|
||
if [ -z "${GO_VERSION:-}" ]; then
|
||
msg_info "Setup Go (apk)"
|
||
apk add --no-cache go >/dev/null 2>&1 || {
|
||
msg_error "apk add go failed"
|
||
return 1
|
||
}
|
||
msg_ok "Go ready: $(go version 2>/dev/null)"
|
||
return 0
|
||
fi
|
||
|
||
need_tool curl tar || return 1
|
||
local ARCH TARBALL URL TMP
|
||
case "$(uname -m)" in
|
||
x86_64) ARCH="amd64" ;;
|
||
aarch64) ARCH="arm64" ;;
|
||
*)
|
||
msg_error "Unsupported arch for Go: $(uname -m)"
|
||
return 1
|
||
;;
|
||
esac
|
||
TARBALL="go${GO_VERSION}.linux-${ARCH}.tar.gz"
|
||
URL="https://go.dev/dl/${TARBALL}"
|
||
msg_info "Setup Go $GO_VERSION (tarball)"
|
||
TMP="$(mktemp)"
|
||
download_with_progress "$URL" "$TMP" || return 1
|
||
rm -rf /usr/local/go
|
||
tar -C /usr/local -xzf "$TMP" || {
|
||
msg_error "extract go failed"
|
||
rm -f "$TMP"
|
||
return 1
|
||
}
|
||
rm -f "$TMP"
|
||
ln -sf /usr/local/go/bin/go /usr/local/bin/go
|
||
ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt
|
||
ensure_usr_local_bin_persist
|
||
msg_ok "Go ready: $(go version 2>/dev/null)"
|
||
}
|
||
|
||
# ------------------------------
|
||
# Composer – Alpine
|
||
# uses php83-cli + openssl + phar
|
||
# ------------------------------
|
||
setup_composer() {
|
||
local COMPOSER_BIN="/usr/local/bin/composer"
|
||
if ! has php; then
|
||
# prefers php83
|
||
msg_info "Installing PHP CLI for Composer"
|
||
apk add --no-cache php83-cli php83-openssl php83-phar php83-iconv >/dev/null 2>&1 || {
|
||
# Fallback to generic php if 83 not available
|
||
apk add --no-cache php-cli php-openssl php-phar php-iconv >/dev/null 2>&1 || {
|
||
msg_error "Failed to install php-cli for composer"
|
||
return 1
|
||
}
|
||
}
|
||
msg_ok "PHP CLI ready: $(php -v | head -n1)"
|
||
fi
|
||
|
||
if [ -x "$COMPOSER_BIN" ]; then
|
||
msg_info "Updating Composer"
|
||
else
|
||
msg_info "Setup Composer"
|
||
fi
|
||
|
||
need_tool curl || return 1
|
||
curl -fsSL https://getcomposer.org/installer -o /tmp/composer-setup.php || {
|
||
msg_error "composer installer download failed"
|
||
return 1
|
||
}
|
||
php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer >/dev/null 2>&1 || {
|
||
msg_error "composer install failed"
|
||
return 1
|
||
}
|
||
rm -f /tmp/composer-setup.php
|
||
ensure_usr_local_bin_persist
|
||
msg_ok "Composer ready: $(composer --version 2>/dev/null)"
|
||
}
|