#!/usr/bin/env bash set -euo pipefail REPO="${AETHER_REPO:-fawney19/Aether}" SOURCE_REF="${AETHER_SOURCE_REF:-main}" VERSION="${AETHER_VERSION:-}" CHANNEL="${AETHER_CHANNEL:-stable}" CHANNEL_EXPLICIT="false" if [[ -n "${AETHER_CHANNEL:-}" ]]; then CHANNEL_EXPLICIT="true" fi MODE="${AETHER_INSTALL_MODE:-auto}" INSTALL_ROOT_EXPLICIT="false" if [[ -n "${INSTALL_ROOT:-}" ]]; then INSTALL_ROOT_EXPLICIT="true" fi INSTALL_ROOT="${INSTALL_ROOT:-/opt/aether}" CONFIG_DIR="${CONFIG_DIR:-/etc/aether}" COMPOSE_DIR="${AETHER_COMPOSE_DIR:-}" COMPOSE_DIR_EXPLICIT="false" if [[ -n "${AETHER_COMPOSE_DIR:-}" ]]; then COMPOSE_DIR_EXPLICIT="true" fi IMAGE_REPO="${AETHER_IMAGE_REPO:-ghcr.io/fawney19/aether}" APP_IMAGE="${AETHER_APP_IMAGE:-}" SERVICE_USER_EXPLICIT="false" SERVICE_GROUP_EXPLICIT="false" if [[ -n "${SERVICE_USER:-}" ]]; then SERVICE_USER_EXPLICIT="true" fi if [[ -n "${SERVICE_GROUP:-}" ]]; then SERVICE_GROUP_EXPLICIT="true" fi SERVICE_USER="${SERVICE_USER:-aether}" SERVICE_GROUP="${SERVICE_GROUP:-aether}" SERVICE_NAME="aether-gateway" COMPOSE_RELEASE_BASE_DIR="/opt/aether" COMPOSE_RELEASE_CURRENT_DIR="${COMPOSE_RELEASE_BASE_DIR}/current" COMPOSE_RELEASE_FRONTEND_DIR="${COMPOSE_RELEASE_CURRENT_DIR}/frontend" COMPOSE_RELEASE_LOG_DIR="${COMPOSE_RELEASE_BASE_DIR}/logs" COMPOSE_RELEASE_SQLITE_DATABASE_URL="sqlite://${COMPOSE_RELEASE_BASE_DIR}/data/aether.db" COMPOSE_LOG_DESTINATION_DEFAULT="stdout" COMPOSE_LOG_FORMAT_DEFAULT="pretty" COMPOSE_LOG_ROTATION_DEFAULT="daily" COMPOSE_LOG_RETENTION_DAYS_DEFAULT="7" COMPOSE_LOG_MAX_FILES_DEFAULT="30" COMPOSE_APP_PORT_DEFAULT="8084" COMPOSE_CLI=() LAUNCHD_LABEL="${AETHER_LAUNCHD_LABEL:-com.aether.gateway}" LAUNCHD_LOG_DIR="${AETHER_LAUNCHD_LOG_DIR:-/var/log/aether}" ENV_TARGET="${CONFIG_DIR}/aether-gateway.env" SYSTEMD_UNIT_PATH="/etc/systemd/system/${SERVICE_NAME}.service" LAUNCHD_PLIST_PATH="/Library/LaunchDaemons/${LAUNCHD_LABEL}.plist" TMP_ROOT="" ARCHIVE_PATH="" BUNDLE_DIR="" ENV_SOURCE="" SKIP_START="false" GENERATED_ENV="" ADMIN_PASSWORD_SOURCE="" UI_LANG="${AETHER_LANG:-${AETHER_LANGUAGE:-auto}}" RELEASE_KEEP="${AETHER_RELEASE_KEEP:-3}" RELEASE_ARCHIVE_URL="${AETHER_RELEASE_ARCHIVE_URL:-${AETHER_DOWNLOAD_URL:-}}" usage() { cat <<'EOF' Usage: install.sh [options] Install Aether Gateway. Options: --mode MODE Deployment mode: compose, compose-single-node, or single-node compose: Docker Compose app + Postgres + Redis compose-single-node: Docker Compose single-node app single-node: single-node system service Linux services use systemd; macOS services use launchd --channel CHANNEL Release channel to resolve when --version is omitted: stable, latest, rc, or beta stable/latest resolves the latest stable tag (default) rc resolves the latest tag like v0.7.0-rc.1 beta resolves the latest tag like v0.7.0-beta.1 --version VERSION Exact release tag to install, for example v0.7.0-rc.1 --repo OWNER/REPO GitHub repository to download from (default: fawney19/Aether) --source-ref REF Source branch/tag used for compose templates (default: main) --archive PATH Install from a local release tarball instead of downloading --download-url URL Download the release archive from this URL instead of GitHub --env-file PATH Use an existing aether-gateway.env file --install-root PATH Install root for system service mode (default: /opt/aether) Also makes the default Docker Compose directory PATH/compose --compose-dir PATH Docker Compose deployment directory (default: current directory) --config-dir PATH Config directory (default: /etc/aether) --lang LANG Installer language: zh or en --skip-start Install files, but do not start Docker Compose or restart the service --keep-releases N Keep the latest N releases, prune older ones (default: 3, 0=disable) -h, --help Show this help Environment overrides: AETHER_REPO, AETHER_SOURCE_REF, AETHER_INSTALL_MODE, AETHER_CHANNEL, AETHER_VERSION AETHER_LANG or AETHER_LANGUAGE AETHER_RELEASE_ARCHIVE_URL or AETHER_DOWNLOAD_URL AETHER_LAUNCHD_LABEL, AETHER_LAUNCHD_LOG_DIR, AETHER_RELEASE_KEEP AETHER_IMAGE_REPO, AETHER_APP_IMAGE INSTALL_ROOT, AETHER_COMPOSE_DIR, CONFIG_DIR, SERVICE_USER, SERVICE_GROUP ADMIN_PASSWORD (required for non-interactive first install when generating a new env) EOF } die() { if ui_is_zh; then echo "错误: $*" >&2 else echo "ERROR: $*" >&2 fi exit 1 } info() { echo ">>> $*" >&2 } warn() { if ui_is_zh; then echo "警告: $*" >&2 else echo "WARNING: $*" >&2 fi } ui_is_zh() { case "${UI_LANG}" in zh|zh-*|cn|chinese|Chinese|中文) return 0 ;; *) return 1 ;; esac } interactive_tty_available() { [[ -r /dev/tty && -w /dev/tty ]] } normalize_ui_lang() { local value="$1" value="$(printf '%s' "${value}" | tr '[:upper:]' '[:lower:]')" case "${value}" in zh|zh-cn|cn|chinese|中文) echo "zh" ;; en|en-us|english|英语) echo "en" ;; auto|"") echo "auto" ;; *) die "unsupported installer language: ${value}; expected zh or en" ;; esac } select_language() { UI_LANG="$(normalize_ui_lang "${UI_LANG}")" if [[ "${UI_LANG}" != "auto" ]]; then return fi if interactive_tty_available; then cat >/dev/tty <<'EOF' 请选择安装语言 / Choose installer language: 1) 中文 2) English 请输入选项 / Enter choice [1]: EOF local choice IFS= read -r choice /dev/null } require_root() { if [[ "${EUID}" -ne 0 ]]; then if ui_is_zh; then die "请使用 root 运行" else die "run as root" fi fi } require_systemd() { if ! command -v systemctl >/dev/null 2>&1; then if ui_is_zh; then die "未找到 systemctl" else die "systemctl not found" fi fi } require_launchd() { if ! command -v launchctl >/dev/null 2>&1; then if ui_is_zh; then die "未找到 launchctl" else die "launchctl not found" fi fi } require_service_manager() { case "$(install_os)" in linux) require_systemd ;; macos) require_launchd ;; esac } service_manager_name() { case "$(install_os)" in linux) echo "systemd" ;; macos) echo "launchd" ;; esac } select_version() { if [[ -n "${VERSION}" || -n "${ARCHIVE_PATH}" || "${CHANNEL_EXPLICIT}" == "true" ]]; then return fi if interactive_tty_available; then if ui_is_zh; then cat >/dev/tty <<'EOF' 请选择 Aether 版本: 1) 最新正式版 2) 最新 RC 预发布版 3) 最新 Beta 预发布版 4) 指定 tag,例如 v0.7.0-rc.1 请输入选项 [1]: EOF else cat >/dev/tty <<'EOF' Choose Aether version: 1) Latest stable release 2) Latest RC prerelease 3) Latest beta prerelease 4) Exact tag, for example v0.7.0-rc.1 Enter choice [1]: EOF fi local choice IFS= read -r choice /dev/tty <<'EOF' 请输入准确 tag: EOF else cat >/dev/tty <<'EOF' Enter exact tag: EOF fi IFS= read -r VERSION /dev/tty </dev/tty </dev/tty else printf '\nEnter initial admin password: ' >/dev/tty fi stty -echo /dev/tty else printf '\nConfirm initial admin password: ' >/dev/tty fi stty -echo /dev/tty [[ -n "${password}" ]] || { if ui_is_zh; then echo "管理员密码不能为空。" >/dev/tty else echo "Admin password cannot be empty." >/dev/tty fi continue } [[ "${password}" == "${confirm}" ]] || { if ui_is_zh; then echo "两次输入的密码不一致。" >/dev/tty else echo "Passwords did not match." >/dev/tty fi continue } ADMIN_PASSWORD="${password}" ADMIN_PASSWORD_SOURCE="prompt" return done fi if ui_is_zh; then die "非交互式安装生成新配置时必须设置 ADMIN_PASSWORD" else die "ADMIN_PASSWORD is required when installing without an interactive terminal" fi } detect_arch() { case "$(uname -m)" in x86_64|amd64) echo "amd64" ;; aarch64|arm64) echo "arm64" ;; *) die "unsupported CPU architecture: $(uname -m)" ;; esac } download_to() { local url="$1" local output="$2" local mode="${3:-quiet}" local show_progress="false" if [[ "${mode}" == "progress" && -t 2 ]]; then show_progress="true" fi if command -v curl >/dev/null 2>&1; then if [[ "${show_progress}" == "true" ]]; then curl -fL --progress-bar "${url}" -o "${output}" else curl -fsSL "${url}" -o "${output}" fi elif command -v wget >/dev/null 2>&1; then if [[ "${show_progress}" == "true" ]]; then wget -O "${output}" "${url}" else wget -qO "${output}" "${url}" fi else die "curl or wget is required to download release assets" fi } download_stdout() { local url="$1" if command -v curl >/dev/null 2>&1; then curl -fsSL "${url}" elif command -v wget >/dev/null 2>&1; then wget -qO- "${url}" else die "curl or wget is required to download release metadata" fi } select_release_download_urls() { local original_archive_url="$1" if [[ -z "${RELEASE_ARCHIVE_URL}" && interactive_tty_available ]]; then if ui_is_zh; then cat >/dev/tty <<'EOF' 是否使用下载加速源? 1) 否,使用原始 GitHub 地址 2) 是,手动填写新的下载 URL 请输入选项 [1]: EOF else cat >/dev/tty <<'EOF' Use an accelerated download URL? 1) No, use the original GitHub URL 2) Yes, enter a replacement download URL Enter choice [1]: EOF fi local choice IFS= read -r choice /dev/tty </dev/tty </dev/null 2>&1; then COMPOSE_CLI=(docker compose) return fi if command -v docker-compose >/dev/null 2>&1; then COMPOSE_CLI=(docker-compose) return fi if ui_is_zh; then die "未找到可用的 Docker Compose,请先安装 Docker 和 Compose 插件" else die "no usable Docker Compose found; install Docker and the Compose plugin first" fi } compose_command() { resolve_compose_cli printf '%s\n' "${COMPOSE_CLI[*]}" } run_compose() { resolve_compose_cli "${COMPOSE_CLI[@]}" "$@" } compose_next_steps() { local gateway_port local compose_cmd compose_cmd="$(compose_command)" gateway_port="$(awk -F= '/^[[:space:]]*APP_PORT=/{print $2}' "${COMPOSE_DIR}/.env" | tail -n1 | tr -d '[:space:]')" gateway_port="${gateway_port:-8084}" cat </dev/null 2>&1; then openssl rand -base64 "${bytes}" | tr '+/' '-_' | tr -d '=' else od -An -N "${bytes}" -tx1 /dev/urandom | tr -d ' \n' fi } write_generate_keys_script() { local output="$1" local output_dir output_dir_normalized config_dir_normalized output_dir="$(dirname "${output}")" output_dir_normalized="${output_dir%/}" config_dir_normalized="${CONFIG_DIR%/}" [[ -n "${output_dir_normalized}" ]] || output_dir_normalized="/" [[ -n "${config_dir_normalized}" ]] || config_dir_normalized="/" if is_darwin && [[ "${output_dir_normalized}" == "${config_dir_normalized}" ]]; then install_config_dir else install -d -m 0755 "${output_dir}" fi cat > "${output}" <<'EOF' #!/usr/bin/env bash set -euo pipefail urlsafe_rand() { if command -v openssl >/dev/null 2>&1; then openssl rand -base64 "$1" | tr '+/' '-_' | tr -d '=' else od -An -N "$1" -tx1 /dev/urandom | tr -d ' \n' fi } cat < "${tmp_file}" cat "${tmp_file}" > "${file}" rm -f "${tmp_file}" else printf '%s=%s\n' "${key}" "${value}" >> "${file}" fi } trim_whitespace() { local value="$1" value="${value#"${value%%[![:space:]]*}"}" value="${value%"${value##*[![:space:]]}"}" printf '%s' "${value}" } strip_optional_quotes() { local value="$1" if [[ ${#value} -ge 2 ]]; then if [[ "${value:0:1}" == "\"" && "${value: -1}" == "\"" ]]; then value="${value:1:${#value}-2}" elif [[ "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then value="${value:1:${#value}-2}" fi fi printf '%s' "${value}" } is_placeholder_value() { local value="$1" case "${value}" in *change-me*|*change-this*|*your_secure_password_here*|*your_redis_password_here*) return 0 ;; *) return 1 ;; esac } derive_local_bundle_version() { local bundle="$1" local name name="$(basename "${bundle}")" case "${name}" in aether-*-linux-*|aether-*-macos-*) name="${name#aether-}" name="${name%-linux-*}" name="${name%-macos-*}" ;; esac if [[ -z "${name}" || "${name}" == "." || "${name}" == "/" ]]; then name="$(date +%Y%m%d%H%M%S)" fi echo "${name}" } generate_first_install_env() { local output="$1" local jwt_key encryption_key prompt_admin_password jwt_key="$(urlsafe_rand 32)" encryption_key="$(urlsafe_rand 32)" cat > "${output}" < "${output}" < "${output}" </dev/null 2>&1; then info "creating group ${SERVICE_GROUP}" groupadd --system "${SERVICE_GROUP}" fi if ! id -u "${SERVICE_USER}" >/dev/null 2>&1; then info "creating user ${SERVICE_USER}" useradd \ --system \ --gid "${SERVICE_GROUP}" \ --home-dir "${INSTALL_ROOT}" \ --shell "$(find_nologin_shell)" \ "${SERVICE_USER}" fi } macos_next_system_id() { local record_type="$1" local id_attr="$2" dscl . -list "/${record_type}" "${id_attr}" 2>/dev/null | awk ' $NF ~ /^[0-9]+$/ && $NF >= 350 && $NF < 500 { used[$NF] = 1 } END { for (i = 350; i < 500; i++) { if (!(i in used)) { print i exit } } } ' } macos_group_id() { dscl . -read "/Groups/${SERVICE_GROUP}" PrimaryGroupID 2>/dev/null | awk '/PrimaryGroupID:/ { print $2 }' } ensure_macos_service_account() { local gid uid if ! command -v dscl >/dev/null 2>&1; then if ui_is_zh; then die "未找到 dscl,无法创建 macOS 服务账号" else die "dscl not found; cannot create macOS service account" fi fi if ! dscl . -read "/Groups/${SERVICE_GROUP}" >/dev/null 2>&1; then gid="$(macos_next_system_id Groups PrimaryGroupID)" [[ -n "${gid}" ]] || die "could not allocate a macOS service group id" info "creating macOS group ${SERVICE_GROUP}" dscl . -create "/Groups/${SERVICE_GROUP}" dscl . -create "/Groups/${SERVICE_GROUP}" PrimaryGroupID "${gid}" dscl . -create "/Groups/${SERVICE_GROUP}" Password "*" fi gid="$(macos_group_id)" [[ -n "${gid}" ]] || die "could not resolve macOS group id for ${SERVICE_GROUP}" if ! dscl . -read "/Users/${SERVICE_USER}" >/dev/null 2>&1; then uid="$(macos_next_system_id Users UniqueID)" [[ -n "${uid}" ]] || die "could not allocate a macOS service user id" info "creating macOS user ${SERVICE_USER}" dscl . -create "/Users/${SERVICE_USER}" dscl . -create "/Users/${SERVICE_USER}" UserShell /usr/bin/false dscl . -create "/Users/${SERVICE_USER}" RealName "Aether Gateway" dscl . -create "/Users/${SERVICE_USER}" UniqueID "${uid}" dscl . -create "/Users/${SERVICE_USER}" PrimaryGroupID "${gid}" dscl . -create "/Users/${SERVICE_USER}" NFSHomeDirectory "${INSTALL_ROOT}" dscl . -create "/Users/${SERVICE_USER}" IsHidden 1 dscl . -create "/Users/${SERVICE_USER}" Password "*" fi } env_file_value() { local file="$1" local key="$2" awk -v key="${key}" ' { line = $0 sub(/^[[:space:]]*/, "", line) if (line ~ /^#/ || line !~ /^[A-Za-z_][A-Za-z0-9_]*=/) { next } name = line sub(/=.*/, "", name) if (name == key) { value = line sub(/^[^=]*=/, "", value) print value } } ' "${file}" | tail -n1 | tr -d '[:space:]' } ensure_env_matches_requested_mode() { local file="$1" local mode="$2" local topology topology="$(env_file_value "${file}" "AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY")" topology="${topology:-single-node}" if [[ "${mode}" == "cluster" ]]; then [[ "${topology}" == "multi-node" ]] || die "existing env ${file} is ${topology}; set AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=multi-node or use --mode single-node" cluster_env_has_required_backends "${file}" || die "existing multi-node env ${file} must define DATABASE_URL and REDIS_URL" elif [[ "${mode}" == "single-node" && "${topology}" == "multi-node" ]]; then die "existing env ${file} is multi-node; cluster mode is temporarily disabled, edit the env file" fi } cluster_env_has_required_backends() { local file="$1" local database_url redis_url database_url="$(env_file_value "${file}" "AETHER_DATABASE_URL")" [[ -n "${database_url}" ]] || database_url="$(env_file_value "${file}" "DATABASE_URL")" [[ -n "${database_url}" ]] || database_url="$(env_file_value "${file}" "AETHER_GATEWAY_DATA_POSTGRES_URL")" redis_url="$(env_file_value "${file}" "REDIS_URL")" [[ -n "${redis_url}" ]] || redis_url="$(env_file_value "${file}" "AETHER_GATEWAY_DATA_REDIS_URL")" [[ -n "${database_url}" && -n "${redis_url}" ]] } validate_env_file() { local env_file="$1" local raw_line="" local line="" local key="" local value="" local line_no=0 local topology="single-node" local node_role="all" local database_driver="" local runtime_backend="" local db_password="" local redis_password="" local database_url="" local redis_url="" local jwt_secret_key="" local encryption_key="" local video_task_store_path="" local static_dir="" [[ -f "${env_file}" ]] || die "env file not found: ${env_file}" info "validating env file ${env_file}" while IFS= read -r raw_line || [[ -n "${raw_line}" ]]; do line_no=$((line_no + 1)) line="${raw_line%$'\r'}" line="$(trim_whitespace "${line}")" [[ -z "${line}" ]] && continue [[ "${line:0:1}" == "#" ]] && continue [[ "${line}" == export\ * ]] && die "env file ${env_file}:${line_no} must not use 'export'" [[ "${line}" == *'${'* ]] && die "env file ${env_file}:${line_no} must not use variable expansion" [[ "${line}" == *'$('* ]] && die "env file ${env_file}:${line_no} must not use command substitution" [[ "${line}" == *'`'* ]] && die "env file ${env_file}:${line_no} must not use command substitution" [[ "${line}" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || die "env file ${env_file}:${line_no} must be KEY=VALUE" key="${line%%=*}" value="${line#*=}" value="$(strip_optional_quotes "${value}")" case "${key}" in AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY) topology="${value}" ;; AETHER_GATEWAY_NODE_ROLE) node_role="${value}" ;; AETHER_DATABASE_DRIVER) database_driver="$(printf '%s' "${value}" | tr '[:upper:]' '[:lower:]')" ;; AETHER_RUNTIME_BACKEND) runtime_backend="$(printf '%s' "${value}" | tr '[:upper:]' '[:lower:]')" ;; AETHER_DATABASE_URL|DATABASE_URL|AETHER_GATEWAY_DATA_POSTGRES_URL) [[ -n "${value}" ]] && database_url="${value}" ;; REDIS_URL|AETHER_GATEWAY_DATA_REDIS_URL) [[ -n "${value}" ]] && redis_url="${value}" ;; DB_PASSWORD) db_password="${value}" ;; REDIS_PASSWORD) redis_password="${value}" ;; JWT_SECRET_KEY) jwt_secret_key="${value}" ;; ENCRYPTION_KEY|AETHER_GATEWAY_DATA_ENCRYPTION_KEY) [[ -n "${value}" ]] && encryption_key="${value}" ;; AETHER_GATEWAY_VIDEO_TASK_STORE_PATH) video_task_store_path="${value}" ;; AETHER_GATEWAY_STATIC_DIR) static_dir="${value}" ;; esac done < "${env_file}" case "${topology}" in single-node|multi-node) ;; *) die "AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY must be single-node or multi-node" ;; esac case "${node_role}" in all|frontdoor|background) ;; *) die "AETHER_GATEWAY_NODE_ROLE must be all, frontdoor, or background" ;; esac [[ -n "${jwt_secret_key}" ]] || die "JWT_SECRET_KEY is required" [[ -n "${encryption_key}" ]] || die "ENCRYPTION_KEY or AETHER_GATEWAY_DATA_ENCRYPTION_KEY is required" is_placeholder_value "${jwt_secret_key}" && die "JWT_SECRET_KEY still uses the example placeholder" is_placeholder_value "${encryption_key}" && die "ENCRYPTION_KEY still uses the example placeholder" if [[ -n "${database_url}" ]] && is_placeholder_value "${database_url}"; then die "DATABASE_URL still uses the example placeholder" fi if [[ -n "${redis_url}" ]] && is_placeholder_value "${redis_url}"; then die "REDIS_URL still uses the example placeholder" fi local database_is_sqlite="false" if [[ "${database_driver}" == "sqlite" || "${database_url}" == sqlite:* ]]; then database_is_sqlite="true" fi if [[ "${topology}" == "multi-node" ]]; then [[ "${node_role}" != "all" ]] || die "multi-node deployment requires AETHER_GATEWAY_NODE_ROLE=frontdoor or background" [[ -n "${database_url}" ]] || die "multi-node deployment requires AETHER_DATABASE_URL, DATABASE_URL, or AETHER_GATEWAY_DATA_POSTGRES_URL" [[ "${database_is_sqlite}" != "true" ]] || die "multi-node deployment must use shared Postgres/MySQL, not SQLite" [[ -n "${redis_url}" ]] || die "multi-node deployment requires REDIS_URL or AETHER_GATEWAY_DATA_REDIS_URL" [[ "${runtime_backend}" != "memory" ]] || die "multi-node deployment must not use AETHER_RUNTIME_BACKEND=memory" [[ -z "${video_task_store_path}" ]] || die "multi-node deployment must not set AETHER_GATEWAY_VIDEO_TASK_STORE_PATH" else if [[ "${node_role}" != "all" ]]; then warn "single-node deployment usually uses AETHER_GATEWAY_NODE_ROLE=all; split roles are not enabled by this installer" fi if [[ "${runtime_backend}" == "redis" && -z "${redis_url}" ]]; then die "AETHER_RUNTIME_BACKEND=redis requires REDIS_URL or AETHER_GATEWAY_DATA_REDIS_URL" fi if [[ -z "${database_url}" && -z "${redis_url}" ]]; then warn "single-node env is running in minimal mode without full Postgres/Redis persistence" elif [[ "${database_is_sqlite}" == "true" && -z "${redis_url}" ]]; then info "single-node env is using SQLite with in-process runtime coordination" fi fi if is_placeholder_value "${db_password}"; then warn "DB_PASSWORD still uses the example placeholder" fi if is_placeholder_value "${redis_password}"; then warn "REDIS_PASSWORD still uses the example placeholder" fi if [[ -n "${static_dir}" && "${static_dir}" != "${INSTALL_ROOT}/current/frontend" ]]; then warn "AETHER_GATEWAY_STATIC_DIR points to ${static_dir}; install script still publishes frontend to ${INSTALL_ROOT}/current/frontend" fi } resolve_service_env_source() { local mode="$1" if [[ -n "${ENV_SOURCE}" ]]; then [[ -f "${ENV_SOURCE}" ]] || die "env file not found: ${ENV_SOURCE}" ensure_env_matches_requested_mode "${ENV_SOURCE}" "${mode}" echo "${ENV_SOURCE}" return fi if [[ -f "${ENV_TARGET}" ]]; then ensure_env_matches_requested_mode "${ENV_TARGET}" "${mode}" echo "" return fi GENERATED_ENV="${TMP_ROOT:-$(mktemp -d)}/aether-gateway.env" if [[ -z "${TMP_ROOT}" ]]; then TMP_ROOT="$(dirname "${GENERATED_ENV}")" fi if [[ "${mode}" == "cluster" ]]; then info "generating multi-node env file" generate_cluster_env "${GENERATED_ENV}" if ! cluster_env_has_required_backends "${GENERATED_ENV}"; then install_config_dir install_env_target_from "${GENERATED_ENV}" cat </dev/null || true)" current_target="$(basename "${current_target}" 2>/dev/null || true)" local count=0 local dir while IFS= read -r dir; do [[ -n "${dir}" ]] || continue local name name="$(basename "${dir}")" [[ "${name}" != "${current_target}" ]] || continue count=$((count + 1)) done < <(ls -1dt "${releases_dir}"/*/ 2>/dev/null) if [[ "${count}" -ge "${keep}" ]]; then local to_remove to_remove="$(ls -1dt "${releases_dir}"/*/ 2>/dev/null | while IFS= read -r d; do local n n="$(basename "${d}")" [[ "${n}" != "${current_target}" ]] || continue printf '%s\n' "${d}" done | tail -n +$((keep)))" local removed=0 while IFS= read -r dir; do [[ -n "${dir}" ]] || continue info "pruning old release: $(basename "${dir}")" rm -rf "${dir}" removed=$((removed + 1)) done <<< "${to_remove}" if [[ "${removed}" -gt 0 ]]; then if ui_is_zh; then info "已清理 ${removed} 个旧版本(保留最新 ${keep} 个)" else info "pruned ${removed} old release(s), keeping latest ${keep}" fi fi fi } render_systemd_unit() { cat < "${rendered_unit}" info "installing systemd unit to ${SYSTEMD_UNIT_PATH}" install -m 0644 "${rendered_unit}" "${SYSTEMD_UNIT_PATH}" rm -f "${rendered_unit}" systemctl daemon-reload systemctl enable "${SERVICE_NAME}" >/dev/null } restart_service_if_requested() { if [[ "${SKIP_START}" == "true" ]]; then info "skipping service restart" return fi info "restarting ${SERVICE_NAME}" systemctl restart "${SERVICE_NAME}" } print_systemd_next_steps() { local gateway_port local database_driver local database_url gateway_port="$(awk -F= '/^[[:space:]]*APP_PORT=/{print $2}' "${ENV_TARGET}" | tail -n1 | tr -d '[:space:]')" gateway_port="${gateway_port:-8084}" database_driver="$(awk -F= '/^[[:space:]]*AETHER_DATABASE_DRIVER=/{print tolower($2)}' "${ENV_TARGET}" | tail -n1 | tr -d '[:space:]')" database_url="$(awk -F= '/^[[:space:]]*(AETHER_DATABASE_URL|DATABASE_URL|AETHER_GATEWAY_DATA_POSTGRES_URL)=/{print $2}' "${ENV_TARGET}" | tail -n1 | tr -d '[:space:]')" cat < "${wrapper}" <> "${wrapper}" <<'EOF' trim_whitespace() { local value="$1" value="${value#"${value%%[![:space:]]*}"}" value="${value%"${value##*[![:space:]]}"}" printf '%s' "${value}" } strip_optional_quotes() { local value="$1" if [[ ${#value} -ge 2 ]]; then if [[ "${value:0:1}" == "\"" && "${value: -1}" == "\"" ]]; then value="${value:1:${#value}-2}" elif [[ "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then value="${value:1:${#value}-2}" fi fi printf '%s' "${value}" } if [[ ! -r "${ENV_TARGET}" ]]; then echo "Aether env file not found or not readable: ${ENV_TARGET}" >&2 exit 1 fi while IFS= read -r raw_line || [[ -n "${raw_line}" ]]; do line="${raw_line%$'\r'}" line="$(trim_whitespace "${line}")" [[ -z "${line}" ]] && continue [[ "${line:0:1}" == "#" ]] && continue if [[ "${line}" == export\ * || ! "${line}" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then echo "Invalid Aether env line: ${line}" >&2 exit 1 fi key="${line%%=*}" value="${line#*=}" value="$(strip_optional_quotes "${value}")" export "${key}=${value}" done < "${ENV_TARGET}" exec "${AETHER_BIN}" EOF chmod 0755 "${wrapper}" chown root:wheel "${wrapper}" } render_launchd_plist() { local wrapper wrapper="$(launchd_wrapper_path)" cat < Label ${LAUNCHD_LABEL} ProgramArguments ${wrapper} UserName ${SERVICE_USER} GroupName ${SERVICE_GROUP} WorkingDirectory ${INSTALL_ROOT}/current RunAtLoad KeepAlive StandardOutPath ${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.out.log StandardErrorPath ${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.err.log Umask 23 EOF } install_launchd_log_files() { install -d -o root -g wheel -m 0755 "${LAUNCHD_LOG_DIR}" touch "${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.out.log" "${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.err.log" chown "${SERVICE_USER}:${SERVICE_GROUP}" "${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.out.log" "${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.err.log" chmod 0640 "${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.out.log" "${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.err.log" } install_launchd_unit() { local rendered_plist rendered_plist="$(mktemp)" render_launchd_plist > "${rendered_plist}" info "installing launchd plist to ${LAUNCHD_PLIST_PATH}" install_launchd_log_files install -d -o root -g wheel -m 0755 "$(dirname "${LAUNCHD_PLIST_PATH}")" install -o root -g wheel -m 0644 "${rendered_plist}" "${LAUNCHD_PLIST_PATH}" rm -f "${rendered_plist}" } restart_launchd_if_requested() { if [[ "${SKIP_START}" == "true" ]]; then info "skipping launchd service restart" return fi info "restarting ${LAUNCHD_LABEL} with launchd" launchctl bootout system "${LAUNCHD_PLIST_PATH}" >/dev/null 2>&1 || true launchctl bootstrap system "${LAUNCHD_PLIST_PATH}" launchctl kickstart -k "system/${LAUNCHD_LABEL}" } print_launchd_next_steps() { local gateway_port local database_driver local database_url gateway_port="$(awk -F= '/^[[:space:]]*APP_PORT=/{print $2}' "${ENV_TARGET}" | tail -n1 | tr -d '[:space:]')" gateway_port="${gateway_port:-8084}" database_driver="$(awk -F= '/^[[:space:]]*AETHER_DATABASE_DRIVER=/{print tolower($2)}' "${ENV_TARGET}" | tail -n1 | tr -d '[:space:]')" database_url="$(awk -F= '/^[[:space:]]*(AETHER_DATABASE_URL|DATABASE_URL|AETHER_GATEWAY_DATA_POSTGRES_URL)=/{print $2}' "${ENV_TARGET}" | tail -n1 | tr -d '[:space:]')" cat <