#!/usr/bin/env bash set -euo pipefail GITEA_BASE_URL="${ERP_GITEA_BASE_URL:-https://git.awaioi.com}" REPOSITORY="${ERP_UPDATE_REPOSITORY:-awaioi/ERP}" REQUESTED_VERSION="${ERP_INSTALL_VERSION:-}" INSTALL_ROOT="${ERP_INSTALL_ROOT:-}" ALLOW_INSECURE="${ERP_UPDATE_ALLOW_INSECURE_HTTP:-0}" NO_SERVICE="${ERP_INSTALL_NO_SERVICE:-0}" TOKEN="${ERP_GITEA_TOKEN:-${OA_UPDATE_TOKEN:-}}" PUBLIC_URL="${ERP_PUBLIC_URL:-}" PUBLIC_KEY='-----BEGIN PUBLIC KEY----- MCowBQYDK2VwAyEAaErhcY8WZIZvPILmYnfjndVBAdOuWkvhaoHIWqNNdxI= -----END PUBLIC KEY-----' TMP_DIR="" say() { printf '[ERP Install] %s\n' "$*"; } fail() { printf '[ERP Install] ERROR: %s\n' "$*" >&2; exit 1; } usage() { cat <<'EOF' Usage: install.sh [options] --gitea-url URL Gitea public base URL (default: https://git.awaioi.com) --repository O/R Release repository (default: awaioi/ERP) --version VERSION Install one exact stable release --install-root PATH Override installation directory --public-url URL Public URL used for the one-time web setup link --allow-insecure Development only: allow plain HTTP release URLs --no-service Start without systemd/launchd; online update stays disabled EOF } while [[ $# -gt 0 ]]; do case "$1" in --gitea-url) [[ $# -ge 2 ]] || fail '--gitea-url requires a value'; GITEA_BASE_URL="$2"; shift 2 ;; --repository) [[ $# -ge 2 ]] || fail '--repository requires a value'; REPOSITORY="$2"; shift 2 ;; --version) [[ $# -ge 2 ]] || fail '--version requires a value'; REQUESTED_VERSION="$2"; shift 2 ;; --install-root) [[ $# -ge 2 ]] || fail '--install-root requires a value'; INSTALL_ROOT="$2"; shift 2 ;; --public-url) [[ $# -ge 2 ]] || fail '--public-url requires a value'; PUBLIC_URL="$2"; shift 2 ;; --allow-insecure) ALLOW_INSECURE=1; shift ;; --no-service) NO_SERVICE=1; shift ;; -h|--help) usage; exit 0 ;; *) fail "unknown option: $1" ;; esac done cleanup() { if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then rm -rf "$TMP_DIR" fi } trap cleanup EXIT detect_platform() { case "$(uname -s)" in Linux) PLATFORM=linux ;; Darwin) PLATFORM=darwin ;; *) fail "unsupported operating system: $(uname -s)" ;; esac case "$(uname -m)" in x86_64|amd64) ARCH=amd64 ;; arm64|aarch64) ARCH=arm64 ;; *) fail "unsupported CPU architecture: $(uname -m); 32-bit systems are not supported" ;; esac if [[ "$PLATFORM" == "linux" && "$(id -u)" != "0" ]]; then fail 'Linux installation requires root; use: curl ... | sudo -E bash' fi } validate_service_manager() { [[ "$PLATFORM" != "linux" || "$NO_SERVICE" == "1" ]] && return 0 command -v systemctl >/dev/null 2>&1 \ || fail 'systemd is required for a production Linux install; use --no-service only for development' [[ -d /run/systemd/system ]] \ || fail 'systemd is not running on this Linux host; use --no-service only for development' } java_major() { local java_bin="${1:-java}" "$java_bin" -version 2>&1 | awk -F'[".]' '/version/ {print $2; exit}' } has_java_17() { command -v java >/dev/null 2>&1 || return 1 local major major="$(java_major "$(command -v java)")" [[ "$major" =~ ^[0-9]+$ && "$major" -ge 17 ]] } has_openssl_3_ed25519() { command -v openssl >/dev/null 2>&1 || return 1 local version algorithms version="$(openssl version 2>/dev/null || true)" [[ "$version" =~ ^OpenSSL[[:space:]]3\. ]] || return 1 algorithms="$(openssl list -public-key-algorithms 2>/dev/null || true)" grep -qi ED25519 <<< "$algorithms" } install_linux_dependencies() { local need_java="$1" local packages=(ca-certificates) command -v curl >/dev/null 2>&1 || packages+=(curl) command -v tar >/dev/null 2>&1 || packages+=(tar) command -v python3 >/dev/null 2>&1 || packages+=(python3) has_openssl_3_ed25519 || packages+=(openssl) if command -v apt-get >/dev/null 2>&1; then [[ "$need_java" == "0" ]] || packages+=(openjdk-17-jre-headless) say 'Installing missing runtime packages with apt-get...' export DEBIAN_FRONTEND=noninteractive apt-get update apt-get install -y "${packages[@]}" elif command -v dnf >/dev/null 2>&1; then [[ "$need_java" == "0" ]] || packages+=(java-17-openjdk-headless) say 'Installing missing runtime packages with dnf...' dnf install -y "${packages[@]}" elif command -v yum >/dev/null 2>&1; then [[ "$need_java" == "0" ]] || packages+=(java-17-openjdk-headless) say 'Installing missing runtime packages with yum...' yum install -y "${packages[@]}" elif command -v zypper >/dev/null 2>&1; then [[ "$need_java" == "0" ]] || packages+=(java-17-openjdk-headless) say 'Installing missing runtime packages with zypper...' zypper --non-interactive install "${packages[@]}" else fail 'no supported package manager found (apt-get, dnf, yum, or zypper is required)' fi } install_macos_dependencies() { command -v brew >/dev/null 2>&1 || fail 'Homebrew is required to install missing macOS packages' local packages=() has_java_17 || packages+=(openjdk@17) command -v python3 >/dev/null 2>&1 || packages+=(python@3) has_openssl_3_ed25519 || packages+=(openssl@3) if (( ${#packages[@]} > 0 )); then say 'Installing missing runtime packages with Homebrew...' brew install "${packages[@]}" fi local java_prefix openssl_prefix java_prefix="$(brew --prefix openjdk@17 2>/dev/null || true)" openssl_prefix="$(brew --prefix openssl@3 2>/dev/null || true)" [[ -z "$java_prefix" ]] || export PATH="$java_prefix/bin:$PATH" [[ -z "$openssl_prefix" ]] || export PATH="$openssl_prefix/bin:$PATH" } check_and_install_dependencies() { local need_install=0 need_java=0 has_java_17 || { need_install=1; need_java=1; } command -v curl >/dev/null 2>&1 || need_install=1 command -v tar >/dev/null 2>&1 || need_install=1 command -v python3 >/dev/null 2>&1 || need_install=1 has_openssl_3_ed25519 || need_install=1 if [[ "$need_install" == "1" ]]; then if [[ "$PLATFORM" == "linux" ]]; then install_linux_dependencies "$need_java" else install_macos_dependencies fi fi for command in curl tar python3 openssl java; do command -v "$command" >/dev/null 2>&1 || fail "missing command after installation: $command" done has_java_17 || fail 'Java 17 or newer is required and could not be installed' has_openssl_3_ed25519 || fail 'OpenSSL 3 with Ed25519 support is required and could not be installed' JAVA_BIN="$(command -v java)" say "Using Java $(java_major "$JAVA_BIN") at $JAVA_BIN" } validate_download_url() { case "$1" in https://*) return 0 ;; http://*) [[ "$ALLOW_INSECURE" == "1" || "$ALLOW_INSECURE" == "true" ]] && return 0 ;; esac fail 'release server must use HTTPS (use --allow-insecure only for an isolated test server)' } create_curl_config() { CURL_CONFIG="$TMP_DIR/curl.conf" { printf 'silent\nshow-error\nfail\nlocation\nconnect-timeout = 15\nmax-time = 900\n' if [[ "$ALLOW_INSECURE" == "1" || "$ALLOW_INSECURE" == "true" ]]; then printf 'proto = "=http,https"\nproto-redir = "=http,https"\n' else printf 'proto = "=https"\nproto-redir = "=https"\n' fi if [[ -n "$TOKEN" ]]; then [[ "$TOKEN" =~ ^[A-Za-z0-9._-]+$ ]] || fail 'invalid Gitea token format' printf 'header = "Authorization: token %s"\n' "$TOKEN" fi } > "$CURL_CONFIG" chmod 600 "$CURL_CONFIG" } download() { validate_download_url "$1" curl --config "$CURL_CONFIG" --output "$2" "$1" } download_release() { [[ "$REPOSITORY" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || fail 'invalid Gitea repository' local owner="${REPOSITORY%%/*}" repo="${REPOSITORY#*/}" local release_json="$TMP_DIR/release.json" download "${GITEA_BASE_URL%/}/api/v1/repos/$owner/$repo/releases/latest" "$release_json" \ || fail 'unable to read the latest Gitea Release' local selection="$TMP_DIR/selection" selection_error="$TMP_DIR/selection-error" if ! python3 - "$release_json" "$REQUESTED_VERSION" > "$selection" 2> "$selection_error" <<'PY' import json, re, sys try: with open(sys.argv[1], encoding="utf-8") as handle: release = json.load(handle) except (OSError, json.JSONDecodeError): raise SystemExit("invalid release JSON") tag = str(release.get("tag_name") or "").strip() match = re.fullmatch(r"v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)", tag) if not match or release.get("draft") or release.get("prerelease"): raise SystemExit("invalid stable release") version = match.group(1) if "-" in version.split("+", 1)[0]: raise SystemExit("prerelease rejected by stable installer") requested = sys.argv[2].removeprefix("v") if requested and requested != version: raise SystemExit("requested version does not match latest release") assets = {str(a.get("name")): str(a.get("browser_download_url") or "") for a in release.get("assets", [])} names = [ f"kaidi-erp-{version}.tar.gz", f"kaidi-erp-installer-{version}.jar", "SHA256SUMS", "SHA256SUMS.sig", ] urls = [assets.get(name, "") for name in names] if any(not url or any(ch.isspace() for ch in url) for url in urls): raise SystemExit("release assets missing") print("\t".join([version, *urls])) PY then local reason="invalid Release metadata" [[ ! -s "$selection_error" ]] || reason="$(<"$selection_error")" fail "$reason" fi local archive_url installer_url sums_url signature_url IFS=$'\t' read -r VERSION archive_url installer_url sums_url signature_url < "$selection" ARCHIVE_NAME="kaidi-erp-${VERSION}.tar.gz" INSTALLER_NAME="kaidi-erp-installer-${VERSION}.jar" ARCHIVE_PATH="$TMP_DIR/$ARCHIVE_NAME" INSTALLER_PATH="$TMP_DIR/$INSTALLER_NAME" download "$archive_url" "$ARCHIVE_PATH" download "$installer_url" "$INSTALLER_PATH" download "$sums_url" "$TMP_DIR/SHA256SUMS" download "$signature_url" "$TMP_DIR/SHA256SUMS.sig" } checksum_for() { python3 - "$TMP_DIR/SHA256SUMS" "$1" <<'PY' import re, sys for line in open(sys.argv[1], encoding="utf-8"): match = re.fullmatch(r"([0-9a-fA-F]{64})\s+\*?(.+?)\s*", line) if match and match.group(2) == sys.argv[2]: print(match.group(1).lower()) break PY } actual_checksum() { if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' else shasum -a 256 "$1" | awk '{print $1}' fi } verify_release() { printf '%s\n' "$PUBLIC_KEY" > "$TMP_DIR/release-public-key.pem" openssl pkeyutl -verify -rawin -pubin -inkey "$TMP_DIR/release-public-key.pem" \ -sigfile "$TMP_DIR/SHA256SUMS.sig" -in "$TMP_DIR/SHA256SUMS" >/dev/null \ || fail 'Release Ed25519 signature verification failed' local name path expected actual for name in "$ARCHIVE_NAME" "$INSTALLER_NAME"; do if [[ "$name" == "$ARCHIVE_NAME" ]]; then path="$ARCHIVE_PATH"; else path="$INSTALLER_PATH"; fi expected="$(checksum_for "$name")" [[ "$expected" =~ ^[0-9a-f]{64}$ ]] || fail "release checksum is missing for $name" actual="$(actual_checksum "$path")" [[ "$actual" == "$expected" ]] || fail "release checksum verification failed for $name" done python3 - "$ARCHIVE_PATH" <<'PY' import pathlib, sys, tarfile with tarfile.open(sys.argv[1], "r:gz") as archive: for item in archive.getmembers(): path = pathlib.PurePosixPath(item.name) if path.is_absolute() or ".." in path.parts or item.issym() or item.islnk(): raise SystemExit(f"unsafe archive member: {item.name}") PY } shell_setting() { printf '%s=' "$1" printf '%q\n' "$2" } prepare_layout() { if [[ "$PLATFORM" == "linux" ]]; then ERP_USER="${ERP_SERVICE_USER:-kaidi-erp}" [[ "$ERP_USER" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]] || fail 'invalid Linux service user name' id "$ERP_USER" >/dev/null 2>&1 \ || useradd --system --home-dir "$INSTALL_ROOT" --shell /usr/sbin/nologin "$ERP_USER" ERP_GROUP="$(id -gn "$ERP_USER" 2>/dev/null)" \ || fail "unable to resolve the primary group for Linux service user: $ERP_USER" CONFIG_ROOT="${ERP_CONFIG_ROOT:-/etc/kaidi-erp}" STATE_ROOT="${ERP_STATE_ROOT:-/var/lib/kaidi-erp}" LOG_ROOT="${ERP_LOG_ROOT:-/var/log/kaidi-erp}" local path for path in "$INSTALL_ROOT" "$CONFIG_ROOT" "$STATE_ROOT" "$LOG_ROOT"; do [[ "$path" =~ ^/[A-Za-z0-9._/@:+-]+$ ]] \ || fail "Linux installation paths must be absolute and contain only safe characters: $path" done else ERP_USER="$(id -un)" ERP_GROUP="$(id -gn)" CONFIG_ROOT="${ERP_CONFIG_ROOT:-$INSTALL_ROOT/config}" STATE_ROOT="${ERP_STATE_ROOT:-$INSTALL_ROOT/state}" LOG_ROOT="${ERP_LOG_ROOT:-$HOME/Library/Logs/KaidiERP}" fi mkdir -p "$INSTALL_ROOT/releases" "$INSTALL_ROOT/installer" "$INSTALL_ROOT/run" \ "$INSTALL_ROOT/backups" "$CONFIG_ROOT" "$STATE_ROOT" "$LOG_ROOT" if [[ "$PLATFORM" == "linux" ]]; then chown -R "$ERP_USER:$ERP_GROUP" "$INSTALL_ROOT" "$CONFIG_ROOT" "$STATE_ROOT" "$LOG_ROOT" chmod 750 "$CONFIG_ROOT" "$STATE_ROOT" fi [[ ! -e "$STATE_ROOT/install.lock" ]] || fail 'Kaidi ERP is already installed; use the system update page' [[ ! -e "$STATE_ROOT/install.pending" ]] || fail 'a previous installation is still pending; inspect the service before retrying' } install_release_files() { local unpack="$TMP_DIR/unpack" mkdir -p "$unpack" tar -xzf "$ARCHIVE_PATH" -C "$unpack" local source="$unpack/kaidi-erp-${VERSION}" [[ -r "$source/app/kaidi-erp.jar" && -x "$source/bin/erp-run" && -x "$source/bin/erp-update" ]] \ || fail 'release archive is incomplete' [[ -r "$source/VERSION" && "$(<"$source/VERSION")" == "$VERSION" ]] \ || fail 'release archive version mismatch' local destination="$INSTALL_ROOT/releases/$VERSION" replaced="" if [[ -e "$destination" ]]; then replaced="${destination}.replaced-$$" mv "$destination" "$replaced" fi if ! mv "$source" "$destination"; then [[ -z "$replaced" || ! -e "$replaced" ]] || mv "$replaced" "$destination" fail 'unable to write the release directory' fi [[ -z "$replaced" ]] || rm -rf "$replaced" local installer_tmp="$INSTALL_ROOT/installer/.kaidi-erp-installer.jar.tmp-$$" cp "$INSTALLER_PATH" "$installer_tmp" chmod 600 "$installer_tmp" mv "$installer_tmp" "$INSTALL_ROOT/installer/kaidi-erp-installer.jar" python3 - "$INSTALL_ROOT/current" "releases/$VERSION" <<'PY' import os, sys link, target = sys.argv[1:] temporary = f"{link}.new-{os.getpid()}" try: os.symlink(target, temporary) os.replace(temporary, link) finally: if os.path.lexists(temporary): os.unlink(temporary) PY if [[ "$PLATFORM" == "linux" ]]; then chown -R "$ERP_USER:$ERP_GROUP" "$destination" "$INSTALL_ROOT/installer" "$INSTALL_ROOT/current" fi } write_bootstrap_configuration() { CONFIG_FILE="$CONFIG_ROOT/erp.env" PUBLIC_KEY_FILE="$CONFIG_ROOT/release-public-key.pem" PENDING_FILE="$STATE_ROOT/install.pending" INSTALL_LOCK_FILE="$STATE_ROOT/install.lock" OPERATION_LOCK_FILE="$STATE_ROOT/install-operation.lock" SETUP_TOKEN="$(openssl rand -hex 32)" || fail 'unable to generate the one-time setup token' local update_enabled=true port="${ERP_SERVER_PORT:-8091}" [[ "$NO_SERVICE" != "1" ]] || update_enabled=false printf '%s\n' "$PUBLIC_KEY" > "$PUBLIC_KEY_FILE" { shell_setting ERP_INSTALL_ROOT "$INSTALL_ROOT" shell_setting ERP_CONFIG_FILE "$CONFIG_FILE" shell_setting ERP_RUN_DIR "$INSTALL_ROOT/run" shell_setting ERP_STATE_FILE "$STATE_ROOT/update-state.json" shell_setting ERP_INSTALL_PENDING_FILE "$PENDING_FILE" shell_setting ERP_INSTALL_LOCK_FILE "$INSTALL_LOCK_FILE" shell_setting ERP_INSTALL_OPERATION_LOCK_FILE "$OPERATION_LOCK_FILE" shell_setting ERP_INSTALL_LOG_FILE "$STATE_ROOT/install-formal.log" shell_setting ERP_INSTALL_HEALTH_TIMEOUT_SECONDS "${ERP_INSTALL_HEALTH_TIMEOUT_SECONDS:-240}" shell_setting ERP_SETUP_TOKEN "$SETUP_TOKEN" shell_setting ERP_INSTALLER_JAR "$INSTALL_ROOT/installer/kaidi-erp-installer.jar" shell_setting ERP_JAR_PATH "$INSTALL_ROOT/current/app/kaidi-erp.jar" shell_setting ERP_JAVA_BIN "$JAVA_BIN" shell_setting ERP_RELEASE_VERSION "$VERSION" shell_setting ERP_HEALTH_URL "http://127.0.0.1:${port}/api/oa/health" shell_setting ERP_UPDATE_PUBLIC_KEY_FILE "$PUBLIC_KEY_FILE" shell_setting ERP_UPDATE_REQUIRE_SIGNATURE true shell_setting ERP_UPDATE_BACKUP_MODE "${ERP_UPDATE_BACKUP_MODE:-none}" shell_setting ERP_UPDATE_HEALTH_TIMEOUT_SECONDS "${ERP_UPDATE_HEALTH_TIMEOUT_SECONDS:-120}" shell_setting ERP_UPDATE_HEALTH_POLL_SECONDS "${ERP_UPDATE_HEALTH_POLL_SECONDS:-2}" shell_setting SERVER_PORT "$port" shell_setting OA_UPDATE_ENABLED "$update_enabled" shell_setting OA_UPDATE_GITEA_BASE_URL "$GITEA_BASE_URL" shell_setting OA_UPDATE_REPOSITORY "$REPOSITORY" shell_setting OA_UPDATE_CHANNEL stable shell_setting OA_UPDATE_TOKEN "$TOKEN" shell_setting OA_UPDATE_HELPER_COMMAND "$INSTALL_ROOT/current/bin/erp-update" shell_setting OA_UPDATE_STATE_FILE "$STATE_ROOT/update-state.json" shell_setting OA_UPDATE_ALLOW_INSECURE_HTTP "$ALLOW_INSECURE" } > "$CONFIG_FILE" if [[ "$PLATFORM" == "linux" ]]; then chown "$ERP_USER:$ERP_GROUP" "$CONFIG_FILE" "$PUBLIC_KEY_FILE" fi chmod 600 "$CONFIG_FILE" "$PUBLIC_KEY_FILE" } start_service() { if [[ "$NO_SERVICE" == "1" ]]; then nohup env ERP_INSTALL_ROOT="$INSTALL_ROOT" ERP_CONFIG_FILE="$CONFIG_FILE" \ "$INSTALL_ROOT/current/bin/erp-run" >> "$LOG_ROOT/erp.log" 2>> "$LOG_ROOT/erp-error.log" & printf '%s\n' "$!" > "$INSTALL_ROOT/run/launcher.pid" return 0 fi if [[ "$PLATFORM" == "linux" ]]; then # The updater shares this service cgroup and must outlive the Java process # to verify the restarted release and roll back a failed health check. cat > /etc/systemd/system/kaidi-erp.service </dev/null 2>&1; then systemd-analyze verify /etc/systemd/system/kaidi-erp.service \ || fail 'generated systemd unit is invalid; run systemd-analyze verify /etc/systemd/system/kaidi-erp.service' fi systemctl enable kaidi-erp.service systemctl restart kaidi-erp.service else local agents="$HOME/Library/LaunchAgents" plist="$HOME/Library/LaunchAgents/com.kaidi.erp.plist" mkdir -p "$agents" python3 - "$plist" "$INSTALL_ROOT" "$CONFIG_FILE" "$LOG_ROOT" <<'PY' import plistlib, sys path, root, config, logs = sys.argv[1:] payload = { "Label": "com.kaidi.erp", "ProgramArguments": [f"{root}/current/bin/erp-run"], "EnvironmentVariables": {"ERP_INSTALL_ROOT": root, "ERP_CONFIG_FILE": config}, "WorkingDirectory": root, "RunAtLoad": True, "KeepAlive": True, "ThrottleInterval": 3, "StandardOutPath": f"{logs}/erp.log", "StandardErrorPath": f"{logs}/erp-error.log", } with open(path, "wb") as handle: plistlib.dump(payload, handle) PY launchctl bootout "gui/$(id -u)/com.kaidi.erp" >/dev/null 2>&1 || true launchctl bootstrap "gui/$(id -u)" "$plist" fi } wait_for_installer() { local url="http://127.0.0.1:${ERP_SERVER_PORT:-8091}/api/install/status" local deadline=$((SECONDS + 120)) while (( SECONDS < deadline )); do if curl -fsS --connect-timeout 2 --max-time 5 \ -H "X-Setup-Token: $SETUP_TOKEN" "$url" >/dev/null 2>&1; then return 0 fi sleep 2 done fail 'the web installer did not start within 120 seconds; inspect the kaidi-erp service log' } detect_lan_address() { local address="" if command -v hostname >/dev/null 2>&1; then address="$(hostname -I 2>/dev/null | awk '{for (i=1; i<=NF; i++) if ($i ~ /^[0-9]+\./) {print $i; exit}}' || true)" fi if [[ -z "$address" && "$PLATFORM" == "linux" ]] && command -v ip >/dev/null 2>&1; then address="$(ip route get 1.1.1.1 2>/dev/null | awk '/src/ {for (i=1; i<=NF; i++) if ($i=="src") {print $(i+1); exit}}' || true)" fi if [[ -z "$address" && "$PLATFORM" == "darwin" ]] && command -v ipconfig >/dev/null 2>&1; then address="$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || true)" fi printf '%s' "${address:-127.0.0.1}" } is_ip_address() { python3 - "$1" <<'PY' >/dev/null 2>&1 import ipaddress, sys ipaddress.ip_address(sys.argv[1]) PY } detect_public_address() { local endpoint address for endpoint in \ https://api.ipify.org \ https://ifconfig.me/ip \ https://icanhazip.com; do address="$(curl --silent --show-error --fail --location \ --proto '=https' --proto-redir '=https' \ --connect-timeout 3 --max-time 5 "$endpoint" 2>/dev/null \ | tr -d '[:space:]' | head -c 128 || true)" if [[ -n "$address" ]] && is_ip_address "$address"; then printf '%s' "$address" return 0 fi done return 1 } normalize_public_url() { python3 - "$1" <<'PY' import sys from urllib.parse import urlsplit, urlunsplit value = sys.argv[1].strip() try: parsed = urlsplit(value) port = parsed.port except ValueError: raise SystemExit("invalid public URL") if parsed.scheme.lower() not in {"http", "https"} or not parsed.hostname: raise SystemExit("public URL must use http or https") if parsed.username is not None or parsed.password is not None: raise SystemExit("public URL must not contain credentials") if port is not None and not 1 <= port <= 65535: raise SystemExit("invalid public URL port") path = parsed.path or "/" print(urlunsplit((parsed.scheme.lower(), parsed.netloc, path, parsed.query, parsed.fragment))) PY } ip_setup_base_url() { python3 - "$1" "$2" <<'PY' import ipaddress, sys address = ipaddress.ip_address(sys.argv[1]) host = f"[{address}]" if address.version == 6 else str(address) print(f"http://{host}:{int(sys.argv[2])}/") PY } append_setup_token() { python3 - "$1" "$2" <<'PY' import sys from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit parsed = urlsplit(sys.argv[1]) query = [(key, value) for key, value in parse_qsl(parsed.query, keep_blank_values=True) if key != "token"] query.append(("token", sys.argv[2])) print(urlunsplit((parsed.scheme, parsed.netloc, parsed.path or "/", urlencode(query), parsed.fragment))) PY } main() { detect_platform validate_service_manager if [[ -z "$INSTALL_ROOT" ]]; then if [[ "$PLATFORM" == "linux" ]]; then INSTALL_ROOT=/opt/kaidi-erp else INSTALL_ROOT="$HOME/Library/Application Support/KaidiERP" fi fi [[ -n "$GITEA_BASE_URL" ]] || fail 'Gitea URL is required; use --gitea-url or ERP_GITEA_BASE_URL' validate_download_url "$GITEA_BASE_URL" if [[ -n "$PUBLIC_URL" ]]; then case "$PUBLIC_URL" in http://*|https://*) ;; *) fail 'public URL must start with http:// or https://' ;; esac fi say "Detected $PLATFORM/$ARCH" check_and_install_dependencies if [[ -n "$PUBLIC_URL" ]]; then PUBLIC_URL="$(normalize_public_url "$PUBLIC_URL")" || fail 'invalid public URL' fi TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kaidi-erp-install.XXXXXX")" create_curl_config say 'Downloading and verifying the signed release...' download_release verify_release prepare_layout install_release_files write_bootstrap_configuration start_service wait_for_installer local port="${ERP_SERVER_PORT:-8091}" lan_address public_address setup_base local_base lan_base lan_address="$(detect_lan_address)" local_base="http://127.0.0.1:${port}/" lan_base="$(ip_setup_base_url "$lan_address" "$port")" if [[ -n "$PUBLIC_URL" ]]; then setup_base="$PUBLIC_URL" elif public_address="$(detect_public_address)"; then setup_base="$(ip_setup_base_url "$public_address" "$port")" else setup_base="$lan_base" say 'Public IP detection was unavailable; using the LAN address' fi say "Kaidi ERP $VERSION installer is running" say "Setup URL: $(append_setup_token "$setup_base" "$SETUP_TOKEN")" say "Local URL: $(append_setup_token "$local_base" "$SETUP_TOKEN")" if [[ "$lan_address" != "127.0.0.1" && "$lan_base" != "$setup_base" ]]; then say "LAN URL: $(append_setup_token "$lan_base" "$SETUP_TOKEN")" fi say 'Complete PostgreSQL and administrator setup in the browser. The installer will remove itself after the formal service is healthy.' } if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then main "$@" fi