2026-05-06 20:03:07 +08:00
#!/usr/bin/env bash
set -euo pipefail
REPO = " ${ AETHER_REPO :- fawney19 /Aether } "
2026-05-15 01:21:08 +08:00
SOURCE_REF = " ${ AETHER_SOURCE_REF :- main } "
2026-09-04 03:45:52 +08:00
SOURCE_REF_EXPLICIT = "false"
if [[ -n " ${ AETHER_SOURCE_REF :- } " ]] ; then
SOURCE_REF_EXPLICIT = "true"
fi
2026-05-06 20:03:07 +08:00
VERSION = " ${ AETHER_VERSION :- } "
2026-05-14 14:09:27 +08:00
CHANNEL = " ${ AETHER_CHANNEL :- stable } "
2026-05-06 20:03:07 +08:00
CHANNEL_EXPLICIT = "false"
if [[ -n " ${ AETHER_CHANNEL :- } " ]] ; then
CHANNEL_EXPLICIT = "true"
fi
MODE = " ${ AETHER_INSTALL_MODE :- auto } "
2026-05-15 18:43:47 +08:00
INSTALL_ROOT_EXPLICIT = "false"
if [[ -n " ${ INSTALL_ROOT :- } " ]] ; then
INSTALL_ROOT_EXPLICIT = "true"
fi
2026-05-06 20:03:07 +08:00
INSTALL_ROOT = " ${ INSTALL_ROOT :- /opt/aether } "
CONFIG_DIR = " ${ CONFIG_DIR :- /etc/aether } "
2026-05-15 18:43:47 +08:00
COMPOSE_DIR = " ${ AETHER_COMPOSE_DIR :- } "
COMPOSE_DIR_EXPLICIT = "false"
if [[ -n " ${ AETHER_COMPOSE_DIR :- } " ]] ; then
COMPOSE_DIR_EXPLICIT = "true"
fi
2026-05-06 20:03:07 +08:00
IMAGE_REPO = " ${ AETHER_IMAGE_REPO :- ghcr .io/fawney19/aether } "
APP_IMAGE = " ${ AETHER_APP_IMAGE :- } "
2026-05-12 15:36:29 +08:00
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
2026-05-06 20:03:07 +08:00
SERVICE_USER = " ${ SERVICE_USER :- aether } "
SERVICE_GROUP = " ${ SERVICE_GROUP :- aether } "
SERVICE_NAME = "aether-gateway"
2026-05-22 15:15:17 +08:00
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_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 =()
2026-05-12 15:36:29 +08:00
LAUNCHD_LABEL = " ${ AETHER_LAUNCHD_LABEL :- com .aether.gateway } "
LAUNCHD_LOG_DIR = " ${ AETHER_LAUNCHD_LOG_DIR :- /var/log/aether } "
2026-05-06 20:03:07 +08:00
ENV_TARGET = " ${ CONFIG_DIR } /aether-gateway.env"
SYSTEMD_UNIT_PATH = "/etc/systemd/system/ ${ SERVICE_NAME } .service"
2026-05-12 15:36:29 +08:00
LAUNCHD_PLIST_PATH = "/Library/LaunchDaemons/ ${ LAUNCHD_LABEL } .plist"
2026-05-06 20:03:07 +08:00
TMP_ROOT = ""
ARCHIVE_PATH = ""
2026-05-12 15:36:29 +08:00
BUNDLE_DIR = ""
2026-05-06 20:03:07 +08:00
ENV_SOURCE = ""
SKIP_START = "false"
GENERATED_ENV = ""
ADMIN_PASSWORD_SOURCE = ""
2026-05-07 18:53:09 +08:00
UI_LANG = " ${ AETHER_LANG :- ${ AETHER_LANGUAGE :- auto }} "
2026-05-12 15:36:29 +08:00
RELEASE_KEEP = " ${ AETHER_RELEASE_KEEP :- 3 } "
2026-05-07 18:53:09 +08:00
RELEASE_ARCHIVE_URL = " ${ AETHER_RELEASE_ARCHIVE_URL :- ${ AETHER_DOWNLOAD_URL :- }} "
2026-09-04 03:45:52 +08:00
MAX_RELEASE_ARCHIVE_ENTRIES = 100000
MAX_RELEASE_UNPACKED_BYTES = $(( 2 * 1024 * 1024 * 1024 ))
2026-05-06 20:03:07 +08:00
usage() {
cat <<'EOF'
Usage: install.sh [options]
Install Aether Gateway.
Options:
2026-05-16 15:22:42 +08:00
--mode MODE Deployment mode: compose, compose-single-node, or single-node
2026-05-06 20:03:07 +08:00
compose: Docker Compose app + Postgres + Redis
2026-05-16 15:22:42 +08:00
compose-single-node: Docker Compose single-node app
single-node: single-node system service
2026-05-12 15:36:29 +08:00
Linux services use systemd; macOS services use launchd
2026-09-01 16:43:29 +08:00
--channel CHANNEL Release channel to resolve when --version is omitted: stable, latest, rc, beta, or nightly
2026-05-14 14:09:27 +08:00
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
2026-09-01 16:43:29 +08:00
nightly resolves the rolling nightly build from main
--version VERSION Exact release tag to install, for example v0.7.0-rc.1 or nightly
2026-05-06 20:03:07 +08:00
--repo OWNER/REPO GitHub repository to download from (default: fawney19/Aether)
2026-05-15 01:21:08 +08:00
--source-ref REF Source branch/tag used for compose templates (default: main)
2026-05-06 20:03:07 +08:00
--archive PATH Install from a local release tarball instead of downloading
2026-05-07 18:53:09 +08:00
--download-url URL Download the release archive from this URL instead of GitHub
2026-05-06 20:03:07 +08:00
--env-file PATH Use an existing aether-gateway.env file
2026-05-15 18:43:47 +08:00
--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)
2026-05-06 20:03:07 +08:00
--config-dir PATH Config directory (default: /etc/aether)
2026-05-07 18:53:09 +08:00
--lang LANG Installer language: zh or en
2026-05-15 18:43:47 +08:00
--skip-start Install files, but do not start Docker Compose or restart the service
2026-05-12 15:36:29 +08:00
--keep-releases N Keep the latest N releases, prune older ones (default: 3, 0=disable)
2026-05-06 20:03:07 +08:00
-h, --help Show this help
Environment overrides:
AETHER_REPO, AETHER_SOURCE_REF, AETHER_INSTALL_MODE, AETHER_CHANNEL, AETHER_VERSION
2026-05-07 18:53:09 +08:00
AETHER_LANG or AETHER_LANGUAGE
2026-05-07 19:32:39 +08:00
AETHER_RELEASE_ARCHIVE_URL or AETHER_DOWNLOAD_URL
2026-05-12 15:36:29 +08:00
AETHER_LAUNCHD_LABEL, AETHER_LAUNCHD_LOG_DIR, AETHER_RELEASE_KEEP
2026-05-06 20:03:07 +08:00
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() {
2026-05-07 18:53:09 +08:00
if ui_is_zh; then
echo "错误: $* " >& 2
else
echo "ERROR: $* " >& 2
fi
2026-05-06 20:03:07 +08:00
exit 1
}
info() {
echo ">>> $* " >& 2
}
warn() {
2026-05-07 18:53:09 +08:00
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) 中文
2026-05-16 15:22:42 +08:00
2) English
2026-05-07 18:53:09 +08:00
请输入选项 / Enter choice [1]:
EOF
local choice
IFS = read -r choice </dev/tty || choice = ""
case " ${ choice :- 1 } " in
1)
UI_LANG = "zh"
;;
2)
UI_LANG = "en"
;;
*)
UI_LANG = "zh"
die "无效的语言选项: ${ choice } "
;;
esac
else
UI_LANG = "en"
fi
2026-05-06 20:03:07 +08:00
}
cleanup() {
if [[ -n " ${ TMP_ROOT } " && -d " ${ TMP_ROOT } " ]] ; then
rm -rf " ${ TMP_ROOT } "
fi
}
2026-09-04 03:45:52 +08:00
if [[ " ${ BASH_SOURCE [0] :- $0 } " == " $0 " ]] ; then
trap cleanup EXIT
fi
2026-05-06 20:03:07 +08:00
parse_args() {
while [[ $# -gt 0 ]] ; do
case " $1 " in
--mode)
[[ $# -ge 2 ]] || die "--mode requires a value"
MODE = " $2 "
shift 2
;;
--channel)
[[ $# -ge 2 ]] || die "--channel requires a value"
CHANNEL = " $2 "
CHANNEL_EXPLICIT = "true"
shift 2
;;
--version)
[[ $# -ge 2 ]] || die "--version requires a value"
VERSION = " $2 "
shift 2
;;
--repo)
[[ $# -ge 2 ]] || die "--repo requires a value"
REPO = " $2 "
shift 2
;;
--source-ref)
[[ $# -ge 2 ]] || die "--source-ref requires a value"
SOURCE_REF = " $2 "
2026-09-04 03:45:52 +08:00
SOURCE_REF_EXPLICIT = "true"
2026-05-06 20:03:07 +08:00
shift 2
;;
--archive)
[[ $# -ge 2 ]] || die "--archive requires a path"
ARCHIVE_PATH = " $2 "
shift 2
;;
2026-05-07 18:53:09 +08:00
--download-url| --archive-url| --release-url)
[[ $# -ge 2 ]] || die "--download-url requires a value"
RELEASE_ARCHIVE_URL = " $2 "
shift 2
;;
2026-05-06 20:03:07 +08:00
--env-file)
[[ $# -ge 2 ]] || die "--env-file requires a path"
ENV_SOURCE = " $2 "
shift 2
;;
--install-root)
[[ $# -ge 2 ]] || die "--install-root requires a path"
INSTALL_ROOT = " $2 "
2026-05-15 18:43:47 +08:00
INSTALL_ROOT_EXPLICIT = "true"
2026-05-06 20:03:07 +08:00
shift 2
;;
--compose-dir)
[[ $# -ge 2 ]] || die "--compose-dir requires a path"
COMPOSE_DIR = " $2 "
2026-05-15 18:43:47 +08:00
COMPOSE_DIR_EXPLICIT = "true"
2026-05-06 20:03:07 +08:00
shift 2
;;
--config-dir)
[[ $# -ge 2 ]] || die "--config-dir requires a path"
CONFIG_DIR = " $2 "
ENV_TARGET = " ${ CONFIG_DIR } /aether-gateway.env"
shift 2
;;
2026-05-07 18:53:09 +08:00
--lang| --language)
[[ $# -ge 2 ]] || die "--lang requires a value"
UI_LANG = " $2 "
shift 2
;;
2026-05-06 20:03:07 +08:00
--skip-start)
SKIP_START = "true"
shift
;;
2026-05-12 15:36:29 +08:00
--keep-releases)
[[ $# -ge 2 ]] || die "--keep-releases requires a number"
RELEASE_KEEP = " $2 "
shift 2
;;
2026-05-06 20:03:07 +08:00
-h| --help)
usage
exit 0
;;
*)
die "unknown argument: $1 "
;;
esac
done
}
2026-05-12 15:36:29 +08:00
install_os() {
case " $( uname -s) " in
Linux)
echo "linux"
;;
Darwin)
echo "macos"
;;
*)
if ui_is_zh; then
die "Aether 二进制安装仅支持 Linux 和 macOS"
else
die "Aether binary install is only supported on Linux and macOS"
fi
;;
esac
}
is_darwin() {
[[ " $( install_os) " == "macos" ]]
}
apply_platform_defaults() {
if is_darwin; then
if [[ " ${ SERVICE_USER_EXPLICIT } " != "true" ]] ; then
SERVICE_USER = "_aether"
fi
if [[ " ${ SERVICE_GROUP_EXPLICIT } " != "true" ]] ; then
SERVICE_GROUP = "_aether"
2026-05-07 18:53:09 +08:00
fi
fi
2026-05-06 20:03:07 +08:00
}
2026-05-12 15:36:29 +08:00
require_supported_os() {
install_os >/dev/null
}
2026-05-06 20:03:07 +08:00
require_root() {
if [[ " ${ EUID } " -ne 0 ]] ; then
2026-05-07 18:53:09 +08:00
if ui_is_zh; then
die "请使用 root 运行"
else
die "run as root"
fi
2026-05-06 20:03:07 +08:00
fi
}
require_systemd() {
2026-05-07 18:53:09 +08:00
if ! command -v systemctl >/dev/null 2>& 1; then
if ui_is_zh; then
die "未找到 systemctl"
else
die "systemctl not found"
fi
fi
2026-05-06 20:03:07 +08:00
}
2026-05-12 15:36:29 +08:00
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
}
2026-05-06 20:03:07 +08:00
select_version() {
if [[ -n " ${ VERSION } " || -n " ${ ARCHIVE_PATH } " || " ${ CHANNEL_EXPLICIT } " == "true" ]] ; then
return
fi
2026-05-07 18:53:09 +08:00
if interactive_tty_available; then
if ui_is_zh; then
cat >/dev/tty <<'EOF'
请选择 Aether 版本:
2026-05-14 14:09:27 +08:00
1) 最新正式版
2) 最新 RC 预发布版
3) 最新 Beta 预发布版
2026-09-01 16:43:29 +08:00
4) 最新 nightly 构建版
5) 指定 tag,例如 v0.7.0-rc.1
2026-05-07 18:53:09 +08:00
请输入选项 [1]:
EOF
else
cat >/dev/tty <<'EOF'
2026-05-06 20:03:07 +08:00
Choose Aether version:
2026-05-14 14:09:27 +08:00
1) Latest stable release
2) Latest RC prerelease
3) Latest beta prerelease
2026-09-01 16:43:29 +08:00
4) Latest nightly build
5) Exact tag, for example v0.7.0-rc.1
2026-05-06 20:03:07 +08:00
Enter choice [1]:
EOF
2026-05-07 18:53:09 +08:00
fi
2026-05-06 20:03:07 +08:00
local choice
IFS = read -r choice </dev/tty || choice = ""
case " ${ choice :- 1 } " in
1)
2026-05-14 14:09:27 +08:00
CHANNEL = "stable"
2026-05-06 20:03:07 +08:00
;;
2)
2026-05-14 14:09:27 +08:00
CHANNEL = "rc"
;;
3)
CHANNEL = "beta"
;;
4)
2026-09-01 16:43:29 +08:00
CHANNEL = "nightly"
;;
5)
2026-05-07 18:53:09 +08:00
if ui_is_zh; then
cat >/dev/tty <<'EOF'
请输入准确 tag:
EOF
else
cat >/dev/tty <<'EOF'
2026-05-06 20:03:07 +08:00
Enter exact tag:
EOF
2026-05-07 18:53:09 +08:00
fi
2026-05-06 20:03:07 +08:00
IFS = read -r VERSION </dev/tty || VERSION = ""
2026-05-07 18:53:09 +08:00
if [[ -z " ${ VERSION } " ]] ; then
if ui_is_zh; then
die "准确 tag 不能为空"
else
die "exact tag cannot be empty"
fi
fi
2026-05-06 20:03:07 +08:00
;;
*)
2026-05-07 18:53:09 +08:00
if ui_is_zh; then
die "无效的版本选项: ${ choice } "
else
die "invalid version choice: ${ choice } "
fi
2026-05-06 20:03:07 +08:00
;;
esac
fi
}
2026-09-04 03:45:52 +08:00
is_safe_release_identifier() {
local value = " $1 "
[[ -n " ${ value } " && ${# value } -le 128 ]] || return 1
[[ " ${ value } " = ~ ^[ A-Za-z0-9][ A-Za-z0-9._+-] *$ ]]
}
validate_release_identifier() {
local value = " $1 "
is_safe_release_identifier " ${ value } " \
|| die "release version contains unsafe URL or filesystem characters"
}
validate_installer_source_identifiers() {
[[ ${# REPO } -le 200 && " ${ REPO } " = ~ ^[ A-Za-z0-9][ A-Za-z0-9._-] */[ A-Za-z0-9][ A-Za-z0-9._-] *$ ]] \
|| die "repository must be a safe GitHub OWNER/REPO identifier"
[[ -n " ${ SOURCE_REF } " && ${# SOURCE_REF } -le 240 ]] \
|| die "source ref must be a non-empty identifier of at most 240 characters"
[[ " ${ SOURCE_REF } " = ~ ^[ A-Za-z0-9][ A-Za-z0-9._/-] *$ ]] \
|| die "source ref contains unsafe URL characters"
case "/ ${ SOURCE_REF } /" in
*"//" *| *"/./" *| *"/../" *)
die "source ref contains an unsafe path component"
;;
esac
if [[ -n " ${ VERSION } " ]] ; then
validate_release_identifier " ${ VERSION } "
fi
}
validate_service_account_identifier() {
local kind = " $1 "
local value = " $2 "
[[ -n " ${ value } " && ${# value } -le 64 ]] \
|| die " ${ kind } must be a non-empty account identifier of at most 64 characters"
[[ " ${ value } " = ~ ^[ A-Za-z_][ A-Za-z0-9_.-] *\$ ?$ ]] \
|| die " ${ kind } contains unsafe account-name characters"
}
validate_launchd_label() {
local value = " $1 "
[[ -n " ${ value } " && ${# value } -le 128 \
&& " ${ value } " = ~ ^[ A-Za-z0-9][ A-Za-z0-9._-] *$ ]] \
|| die "launchd label contains unsafe filesystem or service-name characters"
}
validate_managed_absolute_path() {
local kind = " $1 "
local value = " $2 "
[[ " ${ value } " == /* && ${# value } -le 1024 ]] \
|| die " ${ kind } must be an absolute path of at most 1024 characters"
[[ " ${ value } " != *$'\n' * && " ${ value } " != *$'\r' * ]] \
|| die " ${ kind } may not contain line breaks"
[[ " ${ value } " = ~ ^/[ A-Za-z0-9._/+,-] +$ ]] \
|| die " ${ kind } contains characters unsafe for generated service files"
case "/ ${ value #/ } /" in
*"//" *| *"/./" *| *"/../" *)
die " ${ kind } contains an unsafe path component"
;;
esac
}
validate_single_node_managed_paths() {
validate_service_account_identifier "service user" " ${ SERVICE_USER } "
validate_service_account_identifier "service group" " ${ SERVICE_GROUP } "
validate_managed_absolute_path "install root" " ${ INSTALL_ROOT } "
validate_managed_absolute_path "config directory" " ${ CONFIG_DIR } "
validate_managed_absolute_path "env target" " ${ ENV_TARGET } "
validate_privileged_path_ancestor " ${ INSTALL_ROOT } "
validate_privileged_path_ancestor " ${ CONFIG_DIR } "
validate_managed_regular_file " ${ ENV_TARGET } " true
case " $( install_os) " in
linux)
validate_managed_absolute_path "systemd unit path" " ${ SYSTEMD_UNIT_PATH } "
validate_privileged_path_ancestor " ${ SYSTEMD_UNIT_PATH } "
validate_managed_regular_file " ${ SYSTEMD_UNIT_PATH } " true
;;
macos)
validate_launchd_label " ${ LAUNCHD_LABEL } "
validate_managed_absolute_path "launchd plist path" " ${ LAUNCHD_PLIST_PATH } "
validate_managed_absolute_path "launchd log directory" " ${ LAUNCHD_LOG_DIR } "
validate_privileged_path_ancestor " ${ LAUNCHD_PLIST_PATH } "
validate_privileged_path_ancestor " ${ LAUNCHD_LOG_DIR } "
validate_managed_regular_file " ${ LAUNCHD_PLIST_PATH } " true
validate_managed_regular_file " $( launchd_wrapper_path) " true
validate_managed_regular_file \
" ${ LAUNCHD_LOG_DIR } / ${ SERVICE_NAME } .out.log" false
validate_managed_regular_file \
" ${ LAUNCHD_LOG_DIR } / ${ SERVICE_NAME } .err.log" false
;;
esac
}
2026-05-06 20:03:07 +08:00
select_mode() {
case " ${ MODE } " in
compose| docker| docker-compose)
MODE = "compose"
return
;;
2026-05-16 15:22:42 +08:00
compose-single-node| docker-single-node| docker-single-node-compose)
MODE = "compose-single-node"
2026-05-13 11:21:35 +08:00
return
;;
2026-09-07 00:09:42 +08:00
single-node| service| systemd| launchd)
2026-05-16 15:22:42 +08:00
MODE = "single-node"
2026-05-06 20:03:07 +08:00
return
;;
cluster| multi| multi-node)
2026-05-14 15:26:58 +08:00
if ui_is_zh; then
2026-05-16 15:22:42 +08:00
die "集群部署模式暂未开放;请先选择 compose、compose-single-node 或 single-node"
2026-05-14 15:26:58 +08:00
else
2026-05-16 15:22:42 +08:00
die "cluster deployment mode is temporarily disabled; choose compose, compose-single-node, or single-node"
2026-05-14 15:26:58 +08:00
fi
2026-05-06 20:03:07 +08:00
;;
auto| "" )
;;
*)
2026-05-16 15:22:42 +08:00
die "unsupported install mode: ${ MODE } ; expected compose, compose-single-node, or single-node"
2026-05-06 20:03:07 +08:00
;;
esac
2026-05-07 18:53:09 +08:00
if interactive_tty_available; then
if ui_is_zh; then
2026-05-12 15:36:29 +08:00
cat >/dev/tty <<EOF
2026-05-07 18:53:09 +08:00
请选择 Aether 部署模式:
2026-05-16 15:22:42 +08:00
1) Docker Compose 标准部署(Postgres + Redis)
2026-09-07 00:09:42 +08:00
2) Docker Compose 单节点部署(PostgreSQL)
3) 系统服务单节点部署(需提供 PostgreSQL DATABASE_URL)
2026-05-07 18:53:09 +08:00
2026-05-16 15:22:42 +08:00
请输入选项 [3]:
2026-05-07 18:53:09 +08:00
EOF
else
2026-05-12 15:36:29 +08:00
cat >/dev/tty <<EOF
2026-05-06 20:03:07 +08:00
Choose Aether deployment mode:
2026-05-16 15:22:42 +08:00
1) Docker Compose standard deployment (Postgres + Redis)
2026-09-07 00:09:42 +08:00
2) Docker Compose single-node deployment (PostgreSQL)
3) System service single-node deployment (PostgreSQL)
2026-05-06 20:03:07 +08:00
2026-05-16 15:22:42 +08:00
Enter choice [3]:
2026-05-06 20:03:07 +08:00
EOF
2026-05-07 18:53:09 +08:00
fi
2026-05-06 20:03:07 +08:00
local choice
IFS = read -r choice </dev/tty || choice = ""
2026-05-16 15:22:42 +08:00
case " ${ choice :- 3 } " in
2026-05-06 20:03:07 +08:00
1)
MODE = "compose"
;;
2026-05-16 15:22:42 +08:00
2)
MODE = "compose-single-node"
2026-05-06 20:03:07 +08:00
;;
2026-05-16 15:22:42 +08:00
3)
MODE = "single-node"
2026-05-13 11:21:35 +08:00
;;
2026-05-06 20:03:07 +08:00
*)
2026-05-07 18:53:09 +08:00
if ui_is_zh; then
die "无效的部署模式选项: ${ choice } "
else
die "invalid deployment mode choice: ${ choice } "
fi
2026-05-06 20:03:07 +08:00
;;
esac
else
2026-05-16 15:22:42 +08:00
MODE = "single-node"
2026-05-06 20:03:07 +08:00
fi
}
prompt_admin_password() {
if [[ -n " ${ ADMIN_PASSWORD :- } " ]] ; then
ADMIN_PASSWORD_SOURCE = "environment"
return
fi
2026-05-07 18:53:09 +08:00
if interactive_tty_available; then
2026-05-06 20:03:07 +08:00
local password confirm
while true; do
2026-05-07 18:53:09 +08:00
if ui_is_zh; then
printf '\n请输入初始管理员密码: ' >/dev/tty
else
printf '\nEnter initial admin password: ' >/dev/tty
fi
2026-05-06 20:03:07 +08:00
stty -echo </dev/tty
IFS = read -r password </dev/tty || password = ""
stty echo </dev/tty
2026-05-07 18:53:09 +08:00
if ui_is_zh; then
printf '\n请再次输入初始管理员密码: ' >/dev/tty
else
printf '\nConfirm initial admin password: ' >/dev/tty
fi
2026-05-06 20:03:07 +08:00
stty -echo </dev/tty
IFS = read -r confirm </dev/tty || confirm = ""
stty echo </dev/tty
printf '\n' >/dev/tty
[[ -n " ${ password } " ]] || {
2026-05-07 18:53:09 +08:00
if ui_is_zh; then
echo "管理员密码不能为空。" >/dev/tty
else
echo "Admin password cannot be empty." >/dev/tty
fi
2026-05-06 20:03:07 +08:00
continue
}
[[ " ${ password } " == " ${ confirm } " ]] || {
2026-05-07 18:53:09 +08:00
if ui_is_zh; then
echo "两次输入的密码不一致。" >/dev/tty
else
echo "Passwords did not match." >/dev/tty
fi
2026-05-06 20:03:07 +08:00
continue
}
ADMIN_PASSWORD = " ${ password } "
ADMIN_PASSWORD_SOURCE = "prompt"
return
done
fi
2026-05-07 18:53:09 +08:00
if ui_is_zh; then
die "非交互式安装生成新配置时必须设置 ADMIN_PASSWORD"
else
die "ADMIN_PASSWORD is required when installing without an interactive terminal"
fi
2026-05-06 20:03:07 +08:00
}
detect_arch() {
case " $( uname -m) " in
x86_64| amd64)
echo "amd64"
;;
aarch64| arm64)
echo "arm64"
;;
*)
die "unsupported CPU architecture: $( uname -m) "
;;
esac
}
2026-09-04 03:45:52 +08:00
validate_https_download_url() {
local url = " $1 "
local authority
[[ " ${ url } " == https://* ]] || die "remote downloads require an absolute HTTPS URL"
[[ " ${ url } " != *"#" * ]] || die "remote download URLs may not contain a fragment"
authority = " ${ url #https:// } "
authority = " ${ authority %%[/?]* } "
[[ -n " ${ authority } " && " ${ authority } " != *"@" * ]] \
|| die "remote download URLs may not contain credentials or an empty host"
}
2026-05-06 20:03:07 +08:00
download_to() {
local url = " $1 "
local output = " $2 "
2026-05-07 13:39:25 +08:00
local mode = " ${ 3 :- quiet } "
local show_progress = "false"
2026-09-04 03:45:52 +08:00
command -v curl >/dev/null 2>& 1 || die "curl is required for secure remote downloads"
validate_https_download_url " ${ url } "
2026-05-07 13:39:25 +08:00
if [[ " ${ mode } " == "progress" && -t 2 ]] ; then
show_progress = "true"
fi
2026-09-04 03:45:52 +08:00
if [[ " ${ show_progress } " == "true" ]] ; then
curl -fL --proto '=https' --proto-redir '=https' --progress-bar " ${ url } " -o " ${ output } "
2026-05-06 20:03:07 +08:00
else
2026-09-04 03:45:52 +08:00
curl -fsSL --proto '=https' --proto-redir '=https' " ${ url } " -o " ${ output } "
2026-05-06 20:03:07 +08:00
fi
}
download_stdout() {
local url = " $1 "
2026-09-04 03:45:52 +08:00
command -v curl >/dev/null 2>& 1 || die "curl is required for secure remote downloads"
validate_https_download_url " ${ url } "
curl -fsSL --proto '=https' --proto-redir '=https' " ${ url } "
}
verify_release_checksum() {
local archive = " $1 "
local checksum_file = " $2 "
local asset = " $3 "
local expected actual matches
[[ -f " ${ checksum_file } " ]] || die "release checksum manifest is missing"
matches = " $( awk -v asset = " ${ asset } " '
($2 == asset || $2 == "*" asset) && $1 ~ /^[0-9A-Fa-f]{64}$/ {
print tolower($1)
}
' " ${ checksum_file } " ) "
[[ " $( printf '%s\n' " ${ matches } " | awk 'NF { count += 1 } END { print count + 0 }' ) " -eq 1 ]] \
|| die "release checksum manifest must contain exactly one valid entry for ${ asset } "
expected = " $( printf '%s\n' " ${ matches } " | awk 'NF { print; exit }' ) "
if command -v sha256sum >/dev/null 2>& 1; then
actual = " $( sha256sum " ${ archive } " | awk '{print tolower($1)}' ) "
elif command -v shasum >/dev/null 2>& 1; then
actual = " $( shasum -a 256 " ${ archive } " | awk '{print tolower($1)}' ) "
2026-05-06 20:03:07 +08:00
else
2026-09-04 03:45:52 +08:00
die "sha256sum or shasum is required to verify release assets"
2026-05-06 20:03:07 +08:00
fi
2026-09-04 03:45:52 +08:00
[[ " ${ actual } " == " ${ expected } " ]] || die "SHA256 verification failed for ${ asset } "
}
validate_release_archive() {
local archive = " $1 "
local expected_root = " ${ 2 :- } "
local members_file listing_file normalized_file root member normalized permissions mode type
local entry_count size_field tar_version unpacked_bytes
members_file = " ${ TMP_ROOT } /archive-members.txt"
listing_file = " ${ TMP_ROOT } /archive-listing.txt"
normalized_file = " ${ TMP_ROOT } /archive-members-normalized.txt"
LC_ALL = C tar -tzf " ${ archive } " >" ${ members_file } " 2>/dev/null \
|| die "release archive cannot be read"
tar_version = " $( tar --version 2>/dev/null | head -n1 || true ) "
case " ${ tar_version } " in
*bsdtar*)
size_field = 5
;;
*GNU\ tar*)
size_field = 3
;;
*)
die "unsupported tar implementation for safe release validation"
;;
esac
LC_ALL = C tar --numeric-owner -tvzf " ${ archive } " >" ${ listing_file } " 2>/dev/null \
|| die "release archive metadata cannot be read"
[[ -s " ${ members_file } " ]] || die "release archive is empty"
entry_count = " $( wc -l <" ${ members_file } " | tr -d '[:space:]' ) "
[[ " ${ entry_count } " = ~ ^[ 0-9] +$ && " ${ entry_count } " -le " ${ MAX_RELEASE_ARCHIVE_ENTRIES } " ]] \
|| die "release archive contains too many entries"
if [[ " ${ entry_count } " != " $( wc -l <" ${ listing_file } " | tr -d '[:space:]' ) " ]] ; then
die "release archive contains malformed member names"
fi
unpacked_bytes = " $( awk -v size_field = " ${ size_field } " -v max = " ${ MAX_RELEASE_UNPACKED_BYTES } " '
{
size = $size_field
if (size !~ /^[0-9]+$/ || size > max - total) {
exit 1
}
total += size
}
END {
if (total <= max) {
print total
}
}
' " ${ listing_file } " ) " \
|| die "release archive has invalid sizes or exceeds the unpacked size limit"
[[ " ${ unpacked_bytes } " = ~ ^[ 0-9] +$ && " ${ unpacked_bytes } " -le " ${ MAX_RELEASE_UNPACKED_BYTES } " ]] \
|| die "release archive has invalid sizes or exceeds the unpacked size limit"
while IFS = read -r permissions; do
type = " ${ permissions : 0 : 1 } "
mode = " ${ permissions : 0 : 10 } "
[[ " ${ type } " == "-" || " ${ type } " == "d" ]] \
|| die "release archive may contain only regular files and directories"
[[ " ${ mode } " != *[ sStT] * ]] \
|| die "release archive contains unsafe special permissions"
[[ " ${ mode : 5 : 1 } " != "w" && " ${ mode : 8 : 1 } " != "w" ]] \
|| die "release archive contains group- or world-writable members"
done <" ${ listing_file } "
: >" ${ normalized_file } "
root = ""
while IFS = read -r member; do
[[ -n " ${ member } " ]] || die "release archive contains an empty member name"
[[ " ${ member } " != /* && " ${ member } " != *\\ * ]] \
|| die "release archive contains an unsafe member path"
[[ " ${ member } " = ~ ^[ A-Za-z0-9._/@%+= ,-] +/?$ ]] \
|| die "release archive contains a member name with unsafe characters"
normalized = " ${ member %/ } "
[[ -n " ${ normalized } " ]] \
|| die "release archive contains an invalid member path"
case "/ ${ normalized } /" in
*"//" *| *"/./" *| *"/../" *)
die "release archive contains path traversal or an empty path component"
;;
esac
member = " ${ normalized } "
normalized = " ${ member %%/* } "
if [[ -z " ${ root } " ]] ; then
root = " ${ normalized } "
elif [[ " ${ normalized } " != " ${ root } " ]] ; then
die "release archive must contain exactly one top-level bundle directory"
fi
printf '%s\n' " ${ member } " >>" ${ normalized_file } "
done <" ${ members_file } "
[[ -n " ${ root } " ]] || die "release archive did not contain a bundle directory"
if [[ -n " ${ expected_root } " && " ${ root } " != " ${ expected_root } " ]] ; then
die "release archive root ${ root } does not match expected bundle ${ expected_root } "
fi
[[ -z " $( LC_ALL = C sort " ${ normalized_file } " | uniq -d | head -n1) " ]] \
|| die "release archive contains duplicate members"
printf '%s\n' " ${ root } "
}
extract_validated_release_archive() {
local archive = " $1 "
local -a tar_args =( -xzf " ${ archive } " -C " ${ TMP_ROOT } " --no-same-owner --no-acls --no-xattrs)
if tar --version 2>/dev/null | head -n1 | grep -qi 'bsdtar' ; then
tar_args +=( --no-fflags --no-mac-metadata)
elif tar --version 2>/dev/null | head -n1 | grep -qi 'gnu tar' ; then
tar_args +=( --no-selinux)
fi
tar " ${ tar_args [@] } " \
|| die "release archive extraction failed"
2026-05-06 20:03:07 +08:00
}
2026-05-07 18:53:09 +08:00
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 || choice = ""
case " ${ choice :- 1 } " in
1)
;;
2)
if ui_is_zh; then
cat >/dev/tty <<EOF
原始压缩包 URL:
${original_archive_url}
请输入新的压缩包下载 URL:
EOF
else
cat >/dev/tty <<EOF
Original archive URL:
${original_archive_url}
Enter replacement archive download URL:
EOF
fi
IFS = read -r RELEASE_ARCHIVE_URL </dev/tty || RELEASE_ARCHIVE_URL = ""
[[ -n " ${ RELEASE_ARCHIVE_URL } " ]] || {
if ui_is_zh; then
die "新的压缩包下载 URL 不能为空"
else
die "replacement archive download URL cannot be empty"
fi
}
;;
*)
if ui_is_zh; then
die "无效的下载源选项: ${ choice } "
else
die "invalid download source choice: ${ choice } "
fi
;;
esac
fi
if [[ -z " ${ RELEASE_ARCHIVE_URL } " ]] ; then
RELEASE_ARCHIVE_URL = " ${ original_archive_url } "
elif [[ " ${ RELEASE_ARCHIVE_URL } " != " ${ original_archive_url } " ]] ; then
if ui_is_zh; then
info "使用自定义压缩包下载 URL"
info "原始压缩包 URL: ${ original_archive_url } "
else
info "using custom archive download URL"
info "original archive URL: ${ original_archive_url } "
fi
fi
}
2026-05-06 20:03:07 +08:00
raw_project_url() {
local path = " $1 "
printf 'https://raw.githubusercontent.com/%s/%s/%s' " ${ REPO } " " ${ SOURCE_REF } " " ${ path } "
}
2026-05-15 18:43:47 +08:00
same_path() {
local left = " $1 "
local right = " $2 "
local left_dir right_dir left_base right_base
[[ -e " ${ left } " && -e " ${ right } " ]] || return 1
left_dir = " $( cd -- " $( dirname -- " ${ left } " ) " && pwd -P) "
right_dir = " $( cd -- " $( dirname -- " ${ right } " ) " && pwd -P) "
left_base = " $( basename -- " ${ left } " ) "
right_base = " $( basename -- " ${ right } " ) "
[[ " ${ left_dir } / ${ left_base } " == " ${ right_dir } / ${ right_base } " ]]
}
2026-09-04 03:45:52 +08:00
stat_file_mode() {
stat -c '%a' " $1 " 2>/dev/null || stat -f '%Lp' " $1 " 2>/dev/null
}
stat_file_uid() {
stat -c '%u' " $1 " 2>/dev/null || stat -f '%u' " $1 " 2>/dev/null
}
stat_file_gid() {
stat -c '%g' " $1 " 2>/dev/null || stat -f '%g' " $1 " 2>/dev/null
}
stat_file_link_count() {
stat -c '%h' " $1 " 2>/dev/null || stat -f '%l' " $1 " 2>/dev/null
}
validate_managed_regular_file() {
local path = " $1 "
local allow_hardlinks = " ${ 2 :- true } "
[[ ! -L " ${ path } " ]] || die "managed file may not be a symbolic link: ${ path } "
if [[ -e " ${ path } " ]] ; then
[[ -f " ${ path } " ]] || die "managed file path is not a regular file: ${ path } "
if [[ " ${ allow_hardlinks } " != "true" ]] ; then
[[ " $( stat_file_link_count " ${ path } " ) " == "1" ]] \
|| die "managed file may not have multiple hard links: ${ path } "
fi
fi
}
validate_managed_parent_directory() {
local path = " $1 "
local parent
parent = " $( dirname -- " ${ path } " ) "
[[ -d " ${ parent } " && ! -L " ${ parent } " ]] \
|| die "managed file parent must be a real directory: ${ parent } "
}
validate_privileged_path_ancestor() {
local path = " $1 "
local ancestor canonical current mode permissions first
ancestor = " ${ path } "
if [[ ! -d " ${ ancestor } " ]] ; then
ancestor = " $( dirname -- " ${ ancestor } " ) "
fi
while [[ ! -e " ${ ancestor } " && ! -L " ${ ancestor } " ]] ; do
[[ " ${ ancestor } " != "/" && " ${ ancestor } " != "." ]] \
|| die "could not resolve a trusted ancestor for privileged path: ${ path } "
ancestor = " $( dirname -- " ${ ancestor } " ) "
done
[[ -d " ${ ancestor } " ]] \
|| die "privileged path ancestor is not a directory: ${ ancestor } "
current = " ${ ancestor } "
first = "true"
while :; do
[[ " $( stat_file_uid " ${ current } " ) " == "0" ]] \
|| die "privileged path component must be owned by root: ${ current } "
if [[ ! -L " ${ current } " ]] ; then
[[ -d " ${ current } " ]] \
|| die "privileged path component is not a directory: ${ current } "
mode = " $( stat_file_mode " ${ current } " ) "
[[ " ${ mode } " = ~ ^[ 0-7] +$ ]] \
|| die "privileged path component has an invalid mode: ${ current } "
permissions = $(( 0 ${ mode } ))
if (( ( permissions & 0022) != 0 )) ; then
if [[ " ${ first } " == "true" ]] || (( ( permissions & 1000) == 0 )) ; then
die "privileged path component may not be group- or world-writable: ${ current } "
fi
fi
fi
[[ " ${ current } " != "/" ]] || break
current = " $( dirname -- " ${ current } " ) "
first = "false"
done
canonical = " $( cd -- " ${ ancestor } " && pwd -P) " \
|| die "could not resolve privileged path ancestor: ${ ancestor } "
current = " ${ canonical } "
first = "true"
while :; do
[[ -d " ${ current } " && ! -L " ${ current } " ]] \
|| die "resolved privileged path component is not a real directory: ${ current } "
[[ " $( stat_file_uid " ${ current } " ) " == "0" ]] \
|| die "resolved privileged path component must be owned by root: ${ current } "
mode = " $( stat_file_mode " ${ current } " ) "
[[ " ${ mode } " = ~ ^[ 0-7] +$ ]] \
|| die "resolved privileged path component has an invalid mode: ${ current } "
permissions = $(( 0 ${ mode } ))
if (( ( permissions & 0022) != 0 )) ; then
if [[ " ${ first } " == "true" ]] || (( ( permissions & 1000) == 0 )) ; then
die "resolved privileged path component may not be group- or world-writable: ${ current } "
fi
fi
[[ " ${ current } " != "/" ]] || break
current = " $( dirname -- " ${ current } " ) "
first = "false"
done
}
atomic_install_managed_file() {
local source = " $1 "
local target = " $2 "
local mode = " $3 "
local owner = " ${ 4 :- } "
local group = " ${ 5 :- } "
local parent base temporary
[[ -f " ${ source } " && ! -L " ${ source } " ]] \
|| die "managed file source must be a regular file: ${ source } "
if [[ " ${ EUID } " -eq 0 && ( -n " ${ owner } " || -n " ${ group } " ) ]] ; then
validate_privileged_path_ancestor " ${ target } "
fi
validate_managed_parent_directory " ${ target } "
validate_managed_regular_file " ${ target } " true
parent = " $( dirname -- " ${ target } " ) "
base = " $( basename -- " ${ target } " ) "
temporary = " $( mktemp " ${ parent } /. ${ base } .tmp.XXXXXXXX" ) " \
|| die "could not create a temporary managed file in ${ parent } "
local -a install_args =( -m " ${ mode } " )
[[ -z " ${ owner } " ]] || install_args +=( -o " ${ owner } " )
[[ -z " ${ group } " ]] || install_args +=( -g " ${ group } " )
if ! install " ${ install_args [@] } " " ${ source } " " ${ temporary } " ; then
rm -f -- " ${ temporary } "
die "could not stage managed file: ${ target } "
fi
validate_managed_parent_directory " ${ target } "
if [[ -L " ${ target } " || ( -e " ${ target } " && ! -f " ${ target } " ) ]] ; then
rm -f -- " ${ temporary } "
die "managed file target changed to an unsafe type: ${ target } "
fi
case " $( install_os) " in
linux)
if ! mv -fT -- " ${ temporary } " " ${ target } " ; then
rm -f -- " ${ temporary } "
die "could not atomically replace managed file: ${ target } "
fi
;;
macos)
if ! mv -fh -- " ${ temporary } " " ${ target } " ; then
rm -f -- " ${ temporary } "
die "could not atomically replace managed file: ${ target } "
fi
;;
esac
[[ -f " ${ target } " && ! -L " ${ target } " ]] \
|| die "managed file replacement did not produce a regular file: ${ target } "
}
ensure_privileged_directory() {
local path = " $1 "
local mode = " $2 "
local owner = " $3 "
local group = " $4 "
validate_privileged_path_ancestor " ${ path } "
[[ ! -L " ${ path } " ]] || die "privileged directory may not be a symbolic link: ${ path } "
if [[ -e " ${ path } " && ! -d " ${ path } " ]] ; then
die "privileged directory path is not a directory: ${ path } "
fi
install -d -o " ${ owner } " -g " ${ group } " -m " ${ mode } " " ${ path } "
[[ -d " ${ path } " && ! -L " ${ path } " ]] \
|| die "privileged directory became an unsafe path: ${ path } "
}
2026-05-15 18:43:47 +08:00
resolve_compose_dir() {
if [[ -n " ${ COMPOSE_DIR } " ]] ; then
return
fi
if [[ " ${ INSTALL_ROOT_EXPLICIT } " == "true" || " ${ COMPOSE_DIR_EXPLICIT } " == "true" ]] ; then
COMPOSE_DIR = " ${ INSTALL_ROOT } /compose"
else
COMPOSE_DIR = " $( pwd -P) "
fi
}
2026-05-06 20:03:07 +08:00
install_project_file() {
local source_path = " $1 "
local target_path = " $2 "
local mode = " $3 "
local script_dir
2026-09-04 03:45:52 +08:00
script_dir = " $( current_script_dir || true ) "
2026-05-06 20:03:07 +08:00
2026-09-04 03:45:52 +08:00
ensure_directory " $( dirname " ${ target_path } " ) "
if [[ -n " ${ script_dir } " && -f " ${ script_dir } / ${ source_path } " && ! -L " ${ script_dir } / ${ source_path } " ]] ; then
atomic_install_managed_file \
" ${ script_dir } / ${ source_path } " " ${ target_path } " " ${ mode } "
2026-05-06 20:03:07 +08:00
else
2026-09-04 03:45:52 +08:00
local downloaded
downloaded = " $( mktemp) "
download_to " $( raw_project_url " ${ source_path } " ) " " ${ downloaded } "
atomic_install_managed_file " ${ downloaded } " " ${ target_path } " " ${ mode } "
rm -f -- " ${ downloaded } "
2026-05-06 20:03:07 +08:00
fi
}
2026-05-15 18:43:47 +08:00
install_generate_keys_script() {
local target_path = " $1 "
local script_dir
2026-09-04 03:45:52 +08:00
script_dir = " $( current_script_dir || true ) "
2026-05-15 18:43:47 +08:00
2026-09-04 03:45:52 +08:00
ensure_directory " $( dirname " ${ target_path } " ) "
if [[ -n " ${ script_dir } " && -f " ${ script_dir } /generate_keys.sh" && ! -L " ${ script_dir } /generate_keys.sh" ]] ; then
atomic_install_managed_file \
" ${ script_dir } /generate_keys.sh" " ${ target_path } " 0755
2026-05-15 18:43:47 +08:00
else
write_generate_keys_script " ${ target_path } "
fi
}
ensure_directory() {
local path = " $1 "
local mode = " ${ 2 :- 0755 } "
2026-09-04 03:45:52 +08:00
[[ ! -L " ${ path } " ]] || die "managed directory may not be a symbolic link: ${ path } "
if [[ -e " ${ path } " && ! -d " ${ path } " ]] ; then
die "managed directory path is not a directory: ${ path } "
fi
2026-05-15 18:43:47 +08:00
if [[ ! -d " ${ path } " ]] ; then
install -d -m " ${ mode } " " ${ path } "
fi
2026-09-04 03:45:52 +08:00
[[ ! -L " ${ path } " ]] || die "managed directory became a symbolic link: ${ path } "
2026-05-15 18:43:47 +08:00
}
require_compose_runtime() {
2026-05-22 15:15:17 +08:00
resolve_compose_cli
}
resolve_compose_cli() {
if [[ " ${# COMPOSE_CLI [@] } " -gt 0 ]] ; then
return
fi
2026-05-15 18:43:47 +08:00
if docker compose version >/dev/null 2>& 1; then
2026-05-22 15:15:17 +08:00
COMPOSE_CLI =( docker compose)
2026-05-15 18:43:47 +08:00
return
fi
if command -v docker-compose >/dev/null 2>& 1; then
2026-05-22 15:15:17 +08:00
COMPOSE_CLI =( docker-compose)
2026-05-15 18:43:47 +08:00
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() {
2026-05-22 15:15:17 +08:00
resolve_compose_cli
printf '%s\n' " ${ COMPOSE_CLI [*] } "
}
run_compose() {
resolve_compose_cli
" ${ COMPOSE_CLI [@] } " " $@ "
2026-05-15 18:43:47 +08:00
}
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 <<EOF
Install complete.
Docker Compose service:
cd ${COMPOSE_DIR}
2026-05-20 22:02:47 +08:00
./update.sh
2026-05-22 15:15:17 +08:00
${compose_cmd} -f docker-compose.yml ps
${compose_cmd} -f docker-compose.yml logs -f app
2026-05-15 18:43:47 +08:00
Health checks:
curl -fsS http://127.0.0.1:${gateway_port}/_gateway/health
curl -fsS http://127.0.0.1:${gateway_port}/readyz
Install directory:
${COMPOSE_DIR}
EOF
}
compose_manual_start_steps() {
local compose_cmd
compose_cmd = " $( compose_command) "
cat <<EOF
Next steps:
cd ${COMPOSE_DIR}
2026-05-22 15:15:17 +08:00
${compose_cmd} -f docker-compose.yml pull
${compose_cmd} -f docker-compose.yml up -d
${compose_cmd} -f docker-compose.yml logs -f app
2026-05-20 22:02:47 +08:00
Later updates:
cd ${COMPOSE_DIR}
./update.sh
2026-05-15 18:43:47 +08:00
Generate a fresh key set any time:
cd ${COMPOSE_DIR}
./generate_keys.sh
EOF
}
start_compose_deployment() {
2026-05-22 15:15:17 +08:00
local -a compose_args =( --project-directory " ${ COMPOSE_DIR } " -f " ${ COMPOSE_DIR } /docker-compose.yml" )
2026-05-15 18:43:47 +08:00
info "pulling Docker Compose images"
2026-05-22 15:15:17 +08:00
run_compose " ${ compose_args [@] } " pull
info "starting Docker Compose services"
run_compose " ${ compose_args [@] } " up -d
2026-05-15 18:43:47 +08:00
}
2026-05-06 20:03:07 +08:00
resolve_version() {
if [[ -n " ${ VERSION } " ]] ; then
echo " ${ VERSION } "
return
fi
local tag = ""
case " ${ CHANNEL } " in
2026-05-14 14:09:27 +08:00
stable| latest)
2026-05-07 11:28:44 +08:00
tag = " $( download_stdout "https://api.github.com/repos/ ${ REPO } /releases?per_page=50" |
sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' |
2026-05-14 14:09:27 +08:00
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' |
2026-05-07 11:28:44 +08:00
head -n1 || true ) "
;;
2026-05-06 20:03:07 +08:00
rc)
tag = " $( download_stdout "https://api.github.com/repos/ ${ REPO } /releases?per_page=50" |
sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' |
2026-05-14 14:09:27 +08:00
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$' |
head -n1 || true ) "
;;
beta)
tag = " $( download_stdout "https://api.github.com/repos/ ${ REPO } /releases?per_page=50" |
sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' |
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$' |
2026-05-06 20:03:07 +08:00
head -n1 || true ) "
;;
2026-09-01 16:43:29 +08:00
nightly)
# The nightly release is a single rolling tag, so no API listing is
# needed (and unauthenticated release-list calls are rate-limited).
tag = "nightly"
;;
2026-05-06 20:03:07 +08:00
*)
2026-09-01 16:43:29 +08:00
die "unsupported release channel: ${ CHANNEL } ; expected stable, latest, rc, beta, or nightly"
2026-05-06 20:03:07 +08:00
;;
esac
echo " ${ tag } "
}
2026-09-04 03:45:52 +08:00
resolve_compose_release_identity() {
local tag
tag = " $( resolve_version) "
[[ -n " ${ tag } " ]] || die "could not resolve ${ CHANNEL } release tag for ${ REPO } "
validate_release_identifier " ${ tag } "
VERSION = " ${ tag } "
if [[ " ${ SOURCE_REF_EXPLICIT } " != "true" ]] ; then
SOURCE_REF = " ${ tag } "
2026-05-06 20:03:07 +08:00
fi
2026-09-04 03:45:52 +08:00
validate_installer_source_identifiers
}
current_script_dir() {
local source = " ${ BASH_SOURCE [0] :- } "
[[ -n " ${ source } " && -f " ${ source } " ]] || return 1
while [[ -L " ${ source } " ]] ; do
local source_dir target
source_dir = " $( cd -- " $( dirname -- " ${ source } " ) " && pwd -P) "
target = " $( readlink " ${ source } " ) "
if [[ " ${ target } " == /* ]] ; then
source = " ${ target } "
else
source = " ${ source_dir } / ${ target } "
fi
done
[[ -f " ${ source } " ]] || return 1
cd -- " $( dirname -- " ${ source } " ) " && pwd -P
2026-05-06 20:03:07 +08:00
}
2026-05-16 15:22:42 +08:00
ensure_tmp_root() {
if [[ -z " ${ TMP_ROOT } " ]] ; then
TMP_ROOT = " $( mktemp -d) "
fi
}
absolute_path() {
local path = " $1 "
local dir
local base
if [[ " ${ path } " == /* ]] ; then
printf '%s\n' " ${ path } "
return
fi
dir = " $( dirname " ${ path } " ) "
base = " $( basename " ${ path } " ) "
printf '%s/%s\n' " $( cd " ${ dir } " && pwd -P) " " ${ base } "
}
absolute_path_maybe_missing() {
local path = " $1 "
if [[ " ${ path } " == /* ]] ; then
printf '%s\n' " ${ path } "
else
printf '%s/%s\n' " $( pwd -P) " " ${ path } "
fi
}
2026-05-06 20:03:07 +08:00
local_bundle_dir() {
local dir
2026-09-04 03:45:52 +08:00
dir = " $( current_script_dir || true ) "
[[ -n " ${ dir } " ]] || return 1
if [[ -d " ${ dir } " && ! -L " ${ dir } " \
&& -d " ${ dir } /bin" && ! -L " ${ dir } /bin" \
&& -f " ${ dir } /bin/aether-gateway" \
&& ! -L " ${ dir } /bin/aether-gateway" \
&& -x " ${ dir } /bin/aether-gateway" \
&& -d " ${ dir } /frontend" && ! -L " ${ dir } /frontend" ]] ; then
2026-05-06 20:03:07 +08:00
echo " ${ dir } "
fi
}
2026-09-04 03:45:52 +08:00
validate_local_bundle_tree() {
local bundle = " $1 "
local binary = " ${ bundle } /bin/aether-gateway"
local unsafe_path
[[ -d " ${ bundle } " && ! -L " ${ bundle } " ]] \
|| die "release bundle must be a real directory: ${ bundle } "
[[ -d " ${ bundle } /bin" && ! -L " ${ bundle } /bin" ]] \
|| die "release bundle bin path must be a real directory: ${ bundle } /bin"
[[ -f " ${ binary } " && ! -L " ${ binary } " && -x " ${ binary } " ]] \
|| die "release bundle binary must be a regular executable file: ${ binary } "
[[ " $( stat_file_link_count " ${ binary } " ) " == "1" ]] \
|| die "release bundle binary may not have multiple hard links: ${ binary } "
[[ -d " ${ bundle } /frontend" && ! -L " ${ bundle } /frontend" ]] \
|| die "release bundle frontend path must be a real directory: ${ bundle } /frontend"
if ! unsafe_path = " $( find " ${ bundle } " -type l -print -quit) " ; then
die "could not inspect release bundle for symbolic links: ${ bundle } "
fi
[[ -z " ${ unsafe_path } " ]] \
|| die "release bundle may not contain symbolic links: ${ unsafe_path } "
if ! unsafe_path = " $( find " ${ bundle } " ! -type d ! -type f -print -quit) " ; then
die "could not inspect release bundle entry types: ${ bundle } "
fi
[[ -z " ${ unsafe_path } " ]] \
|| die "release bundle may contain only directories and regular files: ${ unsafe_path } "
if ! unsafe_path = " $( find " ${ bundle } " -type f -links +1 -print -quit) " ; then
die "could not inspect release bundle hard links: ${ bundle } "
fi
[[ -z " ${ unsafe_path } " ]] \
|| die "release bundle may not contain multiply-linked files: ${ unsafe_path } "
}
2026-05-06 20:03:07 +08:00
download_or_unpack_bundle() {
TMP_ROOT = " $( mktemp -d) "
2026-09-04 03:45:52 +08:00
local archive_file archive_root archive_source expected_root
2026-05-06 20:03:07 +08:00
if [[ -n " ${ ARCHIVE_PATH } " ]] ; then
[[ -f " ${ ARCHIVE_PATH } " ]] || die "archive not found: ${ ARCHIVE_PATH } "
info "using local archive ${ ARCHIVE_PATH } "
2026-09-04 03:45:52 +08:00
archive_source = " $( absolute_path " ${ ARCHIVE_PATH } " ) "
archive_file = " ${ TMP_ROOT } /local-release.tar.gz"
cp " ${ archive_source } " " ${ archive_file } " \
|| die "could not copy local release archive into the validation directory"
expected_root = ""
2026-05-06 20:03:07 +08:00
else
2026-05-12 15:36:29 +08:00
local os arch
os = " $( install_os) "
2026-05-06 20:03:07 +08:00
arch = " $( detect_arch) "
2026-05-07 19:32:39 +08:00
local tag asset base_url archive_url archive_file
2026-05-06 20:03:07 +08:00
tag = " $( resolve_version) "
[[ -n " ${ tag } " ]] || die "could not resolve ${ CHANNEL } release tag for ${ REPO } "
2026-09-04 03:45:52 +08:00
validate_release_identifier " ${ tag } "
2026-05-06 20:03:07 +08:00
VERSION = " ${ tag } "
2026-05-12 15:36:29 +08:00
asset = "aether- ${ tag } - ${ os } - ${ arch } .tar.gz"
2026-05-06 20:03:07 +08:00
base_url = "https://github.com/ ${ REPO } /releases/download/ ${ tag } "
2026-05-07 18:53:09 +08:00
archive_url = " ${ base_url } / ${ asset } "
2026-05-06 20:03:07 +08:00
archive_file = " ${ TMP_ROOT } / ${ asset } "
2026-05-07 19:32:39 +08:00
select_release_download_urls " ${ archive_url } "
2026-05-07 18:53:09 +08:00
if [[ " ${ RELEASE_ARCHIVE_URL } " == " ${ archive_url } " ]] ; then
info "downloading ${ asset } from ${ REPO } "
elif ui_is_zh; then
info "从自定义 URL 下载 ${ asset } "
else
info "downloading ${ asset } from custom URL"
fi
download_to " ${ RELEASE_ARCHIVE_URL } " " ${ archive_file } " progress
2026-09-04 03:45:52 +08:00
download_to " ${ base_url } /SHA256SUMS" " ${ TMP_ROOT } /SHA256SUMS"
verify_release_checksum " ${ archive_file } " " ${ TMP_ROOT } /SHA256SUMS" " ${ asset } "
expected_root = " ${ asset %.tar.gz } "
2026-05-06 20:03:07 +08:00
fi
2026-09-04 03:45:52 +08:00
archive_root = " $( validate_release_archive " ${ archive_file } " " ${ expected_root } " ) "
extract_validated_release_archive " ${ archive_file } "
local bundle = " ${ TMP_ROOT } / ${ archive_root } "
2026-05-06 20:03:07 +08:00
[[ -n " ${ bundle } " ]] || die "release archive did not contain a bundle directory"
2026-09-04 03:45:52 +08:00
validate_local_bundle_tree " ${ bundle } "
2026-05-12 15:36:29 +08:00
if [[ -z " ${ VERSION } " ]] ; then
VERSION = " $( derive_local_bundle_version " ${ bundle } " ) "
fi
2026-09-04 03:45:52 +08:00
validate_release_identifier " ${ VERSION } "
2026-05-12 15:36:29 +08:00
BUNDLE_DIR = " ${ bundle } "
2026-05-06 20:03:07 +08:00
}
urlsafe_rand() {
local bytes = " $1 "
if command -v openssl >/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 "
2026-09-04 03:45:52 +08:00
local output_dir output_dir_normalized config_dir_normalized rendered
2026-05-12 15:36:29 +08:00
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
2026-09-04 03:45:52 +08:00
ensure_directory " ${ output_dir } "
2026-05-12 15:36:29 +08:00
fi
2026-09-04 03:45:52 +08:00
rendered = " $( mktemp) "
cat > " ${ rendered } " <<'EOF'
2026-05-06 20:03:07 +08:00
#!/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 <<KEYS
JWT_SECRET_KEY=$(urlsafe_rand 32)
ENCRYPTION_KEY=$(urlsafe_rand 32)
2026-09-04 03:45:52 +08:00
DB_PASSWORD=$(urlsafe_rand 32)
REDIS_PASSWORD=$(urlsafe_rand 32)
2026-05-06 20:03:07 +08:00
KEYS
EOF
2026-09-04 03:45:52 +08:00
atomic_install_managed_file " ${ rendered } " " ${ output } " 0755
rm -f -- " ${ rendered } "
}
validate_dotenv_scalar() {
local key = " $1 "
local value = " $2 "
[[ " ${ value } " != *$'\n' * && " ${ value } " != *$'\r' * ]] \
|| die " ${ key } may not contain CR or LF characters"
2026-05-06 20:03:07 +08:00
}
replace_or_append_env() {
local file = " $1 "
local key = " $2 "
local value = " $3 "
2026-09-04 03:45:52 +08:00
local parent base staged mode owner group line
local replaced = "false"
2026-05-06 20:03:07 +08:00
2026-09-04 03:45:52 +08:00
[[ " ${ key } " = ~ ^[ A-Za-z_][ A-Za-z0-9_] *$ ]] \
|| die "invalid dotenv key: ${ key } "
validate_dotenv_scalar " ${ key } " " ${ value } "
validate_managed_parent_directory " ${ file } "
validate_managed_regular_file " ${ file } " true
parent = " $( dirname -- " ${ file } " ) "
base = " $( basename -- " ${ file } " ) "
staged = " $( mktemp " ${ parent } /. ${ base } .edit.XXXXXXXX" ) " \
|| die "could not create a temporary env file in ${ parent } "
if [[ -e " ${ file } " ]] ; then
while IFS = read -r line || [[ -n " ${ line } " ]] ; do
if [[ " ${ replaced } " == "false" && " ${ line } " = ~ ^#?[[ :space:]] *${ key } = ]] ; then
printf '%s=%s\n' " ${ key } " " ${ value } " >>" ${ staged } "
replaced = "true"
else
printf '%s\n' " ${ line } " >>" ${ staged } "
fi
done <" ${ file } "
2026-05-06 20:03:07 +08:00
fi
2026-09-04 03:45:52 +08:00
if [[ " ${ replaced } " == "false" ]] ; then
printf '%s=%s\n' " ${ key } " " ${ value } " >> " ${ staged } "
fi
mode = "0600"
owner = ""
group = ""
if [[ -e " ${ file } " ]] ; then
mode = " $( stat_file_mode " ${ file } " ) "
if [[ " ${ EUID } " -eq 0 ]] ; then
owner = " $( stat_file_uid " ${ file } " ) "
group = " $( stat_file_gid " ${ file } " ) "
fi
fi
atomic_install_managed_file " ${ staged } " " ${ file } " " ${ mode } " " ${ owner } " " ${ group } "
rm -f -- " ${ staged } "
2026-05-06 20:03:07 +08:00
}
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
2026-05-12 15:36:29 +08:00
aether-*-linux-*| aether-*-macos-*)
2026-05-06 20:03:07 +08:00
name = " ${ name #aether- } "
name = " ${ name %-linux-* } "
2026-05-12 15:36:29 +08:00
name = " ${ name %-macos-* } "
2026-05-06 20:03:07 +08:00
;;
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 "
2026-09-07 00:09:42 +08:00
local database_url = " ${ AETHER_DATABASE_URL :- ${ DATABASE_URL :- ${ AETHER_GATEWAY_DATA_POSTGRES_URL :- }}} "
[[ -n " ${ database_url } " ]] || die "PostgreSQL DATABASE_URL is required for system-service installs; set DATABASE_URL or use --mode compose"
case " ${ database_url } " in
postgres://*| postgresql://*) ;;
*) die "only PostgreSQL database URLs are supported" ;;
esac
validate_dotenv_scalar "DATABASE_URL" " ${ database_url } "
2026-05-06 20:03:07 +08:00
local jwt_key encryption_key
prompt_admin_password
jwt_key = " $( urlsafe_rand 32) "
encryption_key = " $( urlsafe_rand 32) "
2026-09-04 03:45:52 +08:00
validate_dotenv_scalar "ADMIN_PASSWORD" " ${ ADMIN_PASSWORD } "
2026-05-06 20:03:07 +08:00
cat > " ${ output } " <<EOF
ENVIRONMENT=production
TZ=Asia/Shanghai
RUST_LOG=aether_gateway=info
AETHER_LOG_DESTINATION=both
AETHER_LOG_FORMAT=pretty
AETHER_LOG_DIR=${INSTALL_ROOT}/logs
AETHER_LOG_ROTATION=daily
AETHER_LOG_RETENTION_DAYS=7
AETHER_LOG_MAX_FILES=30
APP_PORT=${APP_PORT:-8084}
2026-05-23 20:14:26 +08:00
AETHER_BASE_DIR=${INSTALL_ROOT}
AETHER_UPDATE_STRATEGY=self
2026-05-06 20:03:07 +08:00
AETHER_GATEWAY_STATIC_DIR=${INSTALL_ROOT}/current/frontend
AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative
2026-09-03 11:05:59 +08:00
AETHER_GATEWAY_DATABASE_MODE=auto
2026-05-06 20:03:07 +08:00
AETHER_RUNTIME_BACKEND=memory
API_KEY_PREFIX=sk
2026-09-07 00:09:42 +08:00
AETHER_DATABASE_DRIVER=postgres
AETHER_DATABASE_URL=${database_url}
DATABASE_URL=${database_url}
2026-05-06 20:03:07 +08:00
JWT_SECRET_KEY=${jwt_key}
ENCRYPTION_KEY=${encryption_key}
ADMIN_EMAIL=admin@example.local
ADMIN_USERNAME=admin
ADMIN_PASSWORD=${ADMIN_PASSWORD}
EOF
}
generate_cluster_env() {
local output = " $1 "
local jwt_key encryption_key role
prompt_admin_password
jwt_key = " $( urlsafe_rand 32) "
encryption_key = " $( urlsafe_rand 32) "
role = " ${ AETHER_GATEWAY_NODE_ROLE :- frontdoor } "
2026-09-04 03:45:52 +08:00
validate_dotenv_scalar "ADMIN_PASSWORD" " ${ ADMIN_PASSWORD } "
validate_dotenv_scalar "ADMIN_EMAIL" " ${ ADMIN_EMAIL :- admin @example.local } "
validate_dotenv_scalar "ADMIN_USERNAME" " ${ ADMIN_USERNAME :- admin } "
validate_dotenv_scalar "DATABASE_URL" " ${ DATABASE_URL :- } "
validate_dotenv_scalar "REDIS_URL" " ${ REDIS_URL :- } "
2026-05-06 20:03:07 +08:00
cat > " ${ output } " <<EOF
ENVIRONMENT=production
TZ=Asia/Shanghai
RUST_LOG=aether_gateway=info
AETHER_LOG_DESTINATION=both
AETHER_LOG_FORMAT=pretty
AETHER_LOG_DIR=${INSTALL_ROOT}/logs
AETHER_LOG_ROTATION=daily
AETHER_LOG_RETENTION_DAYS=7
AETHER_LOG_MAX_FILES=30
APP_PORT=${APP_PORT:-8084}
2026-05-23 20:14:26 +08:00
AETHER_BASE_DIR=${INSTALL_ROOT}
AETHER_UPDATE_STRATEGY=manual
2026-05-06 20:03:07 +08:00
AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=multi-node
AETHER_GATEWAY_NODE_ROLE=${role}
AETHER_GATEWAY_STATIC_DIR=${INSTALL_ROOT}/current/frontend
AETHER_GATEWAY_VIDEO_TASK_TRUTH_SOURCE_MODE=rust-authoritative
2026-09-03 11:05:59 +08:00
AETHER_GATEWAY_DATABASE_MODE=auto
2026-05-06 20:03:07 +08:00
AETHER_RUNTIME_BACKEND=redis
API_KEY_PREFIX=sk
DATABASE_URL=${DATABASE_URL:-}
REDIS_URL=${REDIS_URL:-}
JWT_SECRET_KEY=${jwt_key}
ENCRYPTION_KEY=${encryption_key}
ADMIN_EMAIL=${ADMIN_EMAIL:-admin@example.local}
ADMIN_USERNAME=${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD=${ADMIN_PASSWORD}
EOF
}
compose_image() {
if [[ -n " ${ APP_IMAGE } " ]] ; then
echo " ${ APP_IMAGE } "
return
fi
local tag = ""
if [[ -n " ${ VERSION } " ]] ; then
tag = " ${ VERSION #v } "
else
case " ${ CHANNEL } " in
2026-05-14 14:09:27 +08:00
stable| latest)
tag = "latest"
;;
2026-09-01 16:43:29 +08:00
rc| beta| nightly)
2026-05-07 11:28:44 +08:00
tag = " ${ CHANNEL } "
2026-05-06 20:03:07 +08:00
;;
*)
2026-09-01 16:43:29 +08:00
die "unsupported release channel: ${ CHANNEL } ; expected stable, latest, rc, beta, or nightly"
2026-05-06 20:03:07 +08:00
;;
esac
fi
printf '%s:%s\n' " ${ IMAGE_REPO } " " ${ tag } "
}
2026-05-22 15:15:17 +08:00
compose_app_port() {
printf '%s\n' " ${ APP_PORT :- ${ COMPOSE_APP_PORT_DEFAULT }} "
}
append_compose_log_env_defaults() {
local output = " $1 "
replace_or_append_env " ${ output } " "AETHER_LOG_DESTINATION" " ${ COMPOSE_LOG_DESTINATION_DEFAULT } "
replace_or_append_env " ${ output } " "AETHER_LOG_FORMAT" " ${ COMPOSE_LOG_FORMAT_DEFAULT } "
replace_or_append_env " ${ output } " "AETHER_LOG_DIR" " ${ COMPOSE_RELEASE_LOG_DIR } "
replace_or_append_env " ${ output } " "AETHER_LOG_ROTATION" " ${ COMPOSE_LOG_ROTATION_DEFAULT } "
replace_or_append_env " ${ output } " "AETHER_LOG_RETENTION_DAYS" " ${ COMPOSE_LOG_RETENTION_DAYS_DEFAULT } "
replace_or_append_env " ${ output } " "AETHER_LOG_MAX_FILES" " ${ COMPOSE_LOG_MAX_FILES_DEFAULT } "
}
2026-05-06 20:03:07 +08:00
generate_compose_env() {
local output = " $1 "
2026-09-07 00:09:42 +08:00
local jwt_key encryption_key db_password redis_password
2026-05-06 20:03:07 +08:00
prompt_admin_password
jwt_key = " $( urlsafe_rand 32) "
encryption_key = " $( urlsafe_rand 32) "
2026-09-04 03:45:52 +08:00
db_password = " $( urlsafe_rand 32) "
redis_password = " $( urlsafe_rand 32) "
2026-05-06 20:03:07 +08:00
cp " ${ COMPOSE_DIR } /.env.example" " ${ output } "
replace_or_append_env " ${ output } " "APP_IMAGE" " $( compose_image) "
2026-05-22 15:15:17 +08:00
replace_or_append_env " ${ output } " "APP_PORT" " $( compose_app_port) "
2026-09-04 03:45:52 +08:00
replace_or_append_env " ${ output } " "DB_PASSWORD" " ${ db_password } "
replace_or_append_env " ${ output } " "REDIS_PASSWORD" " ${ redis_password } "
2026-05-06 20:03:07 +08:00
replace_or_append_env " ${ output } " "JWT_SECRET_KEY" " ${ JWT_SECRET_KEY :- ${ jwt_key }} "
replace_or_append_env " ${ output } " "ENCRYPTION_KEY" " ${ ENCRYPTION_KEY :- ${ encryption_key }} "
replace_or_append_env " ${ output } " "ADMIN_EMAIL" " ${ ADMIN_EMAIL :- admin @example.local } "
replace_or_append_env " ${ output } " "ADMIN_USERNAME" " ${ ADMIN_USERNAME :- admin } "
replace_or_append_env " ${ output } " "ADMIN_PASSWORD" " ${ ADMIN_PASSWORD } "
2026-05-23 20:14:26 +08:00
replace_or_append_env " ${ output } " "AETHER_UPDATE_STRATEGY" "docker"
replace_or_append_env " ${ output } " "AETHER_DOCKER_UPDATE_COMMAND" "./update.sh"
2026-05-22 15:15:17 +08:00
append_compose_log_env_defaults " ${ output } "
2026-09-03 11:05:59 +08:00
replace_or_append_env " ${ output } " "AETHER_GATEWAY_DATABASE_MODE" "auto"
2026-05-06 20:03:07 +08:00
}
2026-05-16 15:22:42 +08:00
generate_compose_single_node_env() {
2026-05-13 11:21:35 +08:00
local output = " $1 "
2026-09-07 00:09:42 +08:00
generate_compose_env " ${ output } "
replace_or_append_env " ${ output } " "AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY" "single-node"
replace_or_append_env " ${ output } " "AETHER_DATABASE_DRIVER" "postgres"
2026-05-13 11:21:35 +08:00
}
2026-05-12 15:36:29 +08:00
install_config_dir() {
2026-09-04 03:45:52 +08:00
validate_privileged_path_ancestor " ${ CONFIG_DIR } "
[[ ! -L " ${ CONFIG_DIR } " ]] || die "config directory may not be a symbolic link: ${ CONFIG_DIR } "
if [[ -e " ${ CONFIG_DIR } " && ! -d " ${ CONFIG_DIR } " ]] ; then
die "config directory path is not a directory: ${ CONFIG_DIR } "
fi
2026-05-12 15:36:29 +08:00
if is_darwin; then
install -d -o root -g " ${ SERVICE_GROUP } " -m 0750 " ${ CONFIG_DIR } "
else
2026-09-04 03:45:52 +08:00
install -d -o root -g root -m 0750 " ${ CONFIG_DIR } "
2026-05-12 15:36:29 +08:00
fi
2026-09-04 03:45:52 +08:00
[[ ! -L " ${ CONFIG_DIR } " ]] || die "config directory became a symbolic link: ${ CONFIG_DIR } "
2026-05-12 15:36:29 +08:00
}
install_env_target_from() {
local source = " $1 "
2026-09-04 03:45:52 +08:00
local owner = "" group = ""
[[ -f " ${ source } " && ! -L " ${ source } " ]] \
|| die "env source must be a regular file and not a symbolic link: ${ source } "
2026-05-12 15:36:29 +08:00
if is_darwin; then
2026-09-04 03:45:52 +08:00
if [[ " ${ EUID } " -eq 0 ]] ; then
owner = "root"
group = " ${ SERVICE_GROUP } "
fi
atomic_install_managed_file " ${ source } " " ${ ENV_TARGET } " 0640 " ${ owner } " " ${ group } "
2026-05-12 15:36:29 +08:00
else
2026-09-04 03:45:52 +08:00
if [[ " ${ EUID } " -eq 0 ]] ; then
owner = "root"
group = "root"
fi
atomic_install_managed_file " ${ source } " " ${ ENV_TARGET } " 0600 " ${ owner } " " ${ group } "
2026-05-12 15:36:29 +08:00
fi
}
ensure_env_target_permissions() {
2026-09-04 03:45:52 +08:00
local owner = "" group = "" mode = "0600"
validate_managed_regular_file " ${ ENV_TARGET } " true
[[ -f " ${ ENV_TARGET } " ]] || die "env target is missing: ${ ENV_TARGET } "
if [[ " ${ EUID } " -eq 0 ]] ; then
owner = "root"
group = "root"
2026-05-12 15:36:29 +08:00
fi
2026-09-04 03:45:52 +08:00
if is_darwin; then
mode = "0640"
if [[ " ${ EUID } " -eq 0 ]] ; then
group = " ${ SERVICE_GROUP } "
fi
fi
atomic_install_managed_file \
" ${ ENV_TARGET } " " ${ ENV_TARGET } " " ${ mode } " " ${ owner } " " ${ group } "
2026-05-12 15:36:29 +08:00
}
2026-05-06 20:03:07 +08:00
install_systemd_support_files() {
2026-05-12 15:36:29 +08:00
install_config_dir
2026-05-06 20:03:07 +08:00
write_generate_keys_script " ${ CONFIG_DIR } /generate_keys.sh"
}
find_nologin_shell() {
if [[ -x /usr/sbin/nologin ]] ; then
echo "/usr/sbin/nologin"
elif [[ -x /sbin/nologin ]] ; then
echo "/sbin/nologin"
else
echo "/bin/false"
fi
}
ensure_service_account() {
if ! getent group " ${ SERVICE_GROUP } " >/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
}
2026-05-12 15:36:29 +08:00
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
}
2026-05-06 20:03:07 +08:00
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
2026-05-16 15:22:42 +08:00
[[ " ${ topology } " == "multi-node" ]] || die "existing env ${ file } is ${ topology } ; set AETHER_GATEWAY_DEPLOYMENT_TOPOLOGY=multi-node or use --mode single-node"
2026-05-06 20:03:07 +08:00
cluster_env_has_required_backends " ${ file } " || die "existing multi-node env ${ file } must define DATABASE_URL and REDIS_URL"
2026-05-16 15:22:42 +08:00
elif [[ " ${ mode } " == "single-node" && " ${ topology } " == "multi-node" ]] ; then
2026-05-14 15:26:58 +08:00
die "existing env ${ file } is multi-node; cluster mode is temporarily disabled, edit the env file"
2026-05-06 20:03:07 +08:00
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
2026-09-07 00:09:42 +08:00
case " ${ database_driver } " in
"" | postgres| postgresql) ;;
*) die "only PostgreSQL database drivers are supported" ;;
esac
case " ${ database_url } " in
postgres://*| postgresql://*) ;;
*) die "a PostgreSQL DATABASE_URL is required" ;;
esac
2026-05-06 20:03:07 +08:00
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"
[[ -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
2026-05-14 15:26:58 +08:00
warn "single-node deployment usually uses AETHER_GATEWAY_NODE_ROLE=all; split roles are not enabled by this installer"
2026-05-06 20:03:07 +08:00
fi
if [[ " ${ runtime_backend } " == "redis" && -z " ${ redis_url } " ]] ; then
die "AETHER_RUNTIME_BACKEND=redis requires REDIS_URL or AETHER_GATEWAY_DATA_REDIS_URL"
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
}
2026-05-12 15:36:29 +08:00
resolve_service_env_source() {
2026-05-06 20:03:07 +08:00
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
2026-05-12 15:36:29 +08:00
install_config_dir
install_env_target_from " ${ GENERATED_ENV } "
2026-05-06 20:03:07 +08:00
cat <<EOF
Multi-node env scaffolded:
${ENV_TARGET}
Fill DATABASE_URL and REDIS_URL, then rerun:
sudo AETHER_INSTALL_MODE=cluster bash install.sh
Or provide them non-interactively:
curl -fsSL https://raw.githubusercontent.com/${REPO}/${SOURCE_REF}/install.sh | sudo DATABASE_URL=postgresql://... REDIS_URL=redis://... bash -s -- --mode cluster
EOF
exit 1
fi
else
2026-05-16 15:22:42 +08:00
info "generating first-install single-node env file"
2026-05-06 20:03:07 +08:00
generate_first_install_env " ${ GENERATED_ENV } "
fi
echo " ${ GENERATED_ENV } "
}
install_compose_mode() {
2026-05-15 18:43:47 +08:00
resolve_compose_dir
2026-05-06 20:03:07 +08:00
info "preparing Docker Compose deployment in ${ COMPOSE_DIR } "
2026-05-15 18:43:47 +08:00
ensure_directory " ${ COMPOSE_DIR } "
2026-05-23 20:14:26 +08:00
ensure_directory " ${ COMPOSE_DIR } /logs"
2026-05-06 20:03:07 +08:00
install_project_file "docker-compose.yml" " ${ COMPOSE_DIR } /docker-compose.yml" "0644"
install_project_file ".env.example" " ${ COMPOSE_DIR } /.env.example" "0644"
2026-05-20 22:02:47 +08:00
install_project_file "update.sh" " ${ COMPOSE_DIR } /update.sh" "0755"
2026-05-15 18:43:47 +08:00
install_generate_keys_script " ${ COMPOSE_DIR } /generate_keys.sh"
2026-05-06 20:03:07 +08:00
2026-09-04 03:45:52 +08:00
validate_managed_regular_file " ${ COMPOSE_DIR } /.env" false
2026-05-06 20:03:07 +08:00
if [[ -f " ${ COMPOSE_DIR } /.env" ]] ; then
warn "keeping existing ${ COMPOSE_DIR } /.env"
else
2026-09-04 03:45:52 +08:00
local generated_compose_env
2026-05-06 20:03:07 +08:00
info "generating ${ COMPOSE_DIR } /.env"
2026-09-04 03:45:52 +08:00
generated_compose_env = " $( mktemp) "
generate_compose_env " ${ generated_compose_env } "
atomic_install_managed_file \
" ${ generated_compose_env } " " ${ COMPOSE_DIR } /.env" 0600
rm -f -- " ${ generated_compose_env } "
2026-05-06 20:03:07 +08:00
fi
cat <<EOF
Docker Compose files are ready:
${COMPOSE_DIR}/docker-compose.yml
${COMPOSE_DIR}/.env
${COMPOSE_DIR}/.env.example
2026-05-20 22:02:47 +08:00
${COMPOSE_DIR}/update.sh
2026-05-06 20:03:07 +08:00
${COMPOSE_DIR}/generate_keys.sh
2026-05-23 20:14:26 +08:00
${COMPOSE_DIR}/logs
2026-05-06 20:03:07 +08:00
EOF
2026-05-15 18:43:47 +08:00
if [[ " ${ SKIP_START } " == "true" ]] ; then
compose_manual_start_steps
return
fi
require_compose_runtime
start_compose_deployment
compose_next_steps
2026-05-06 20:03:07 +08:00
}
2026-05-16 15:22:42 +08:00
install_compose_single_node_mode() {
2026-05-15 18:43:47 +08:00
resolve_compose_dir
2026-05-16 15:22:42 +08:00
info "preparing Docker Compose single-node deployment in ${ COMPOSE_DIR } "
2026-05-15 18:43:47 +08:00
ensure_directory " ${ COMPOSE_DIR } "
2026-05-23 20:14:26 +08:00
ensure_directory " ${ COMPOSE_DIR } /logs"
2026-05-13 11:21:35 +08:00
2026-05-16 15:22:42 +08:00
install_project_file "docker-compose.single-node.yml" " ${ COMPOSE_DIR } /docker-compose.yml" "0644"
2026-05-13 11:21:35 +08:00
install_project_file ".env.example" " ${ COMPOSE_DIR } /.env.example" "0644"
2026-05-20 22:02:47 +08:00
install_project_file "update.sh" " ${ COMPOSE_DIR } /update.sh" "0755"
2026-05-15 18:43:47 +08:00
install_generate_keys_script " ${ COMPOSE_DIR } /generate_keys.sh"
2026-05-13 11:21:35 +08:00
2026-09-04 03:45:52 +08:00
validate_managed_regular_file " ${ COMPOSE_DIR } /.env" false
2026-05-13 11:21:35 +08:00
if [[ -f " ${ COMPOSE_DIR } /.env" ]] ; then
warn "keeping existing ${ COMPOSE_DIR } /.env"
else
2026-09-04 03:45:52 +08:00
local generated_compose_env
2026-05-13 11:21:35 +08:00
info "generating ${ COMPOSE_DIR } /.env"
2026-09-04 03:45:52 +08:00
generated_compose_env = " $( mktemp) "
generate_compose_single_node_env " ${ generated_compose_env } "
atomic_install_managed_file \
" ${ generated_compose_env } " " ${ COMPOSE_DIR } /.env" 0600
rm -f -- " ${ generated_compose_env } "
2026-05-13 11:21:35 +08:00
fi
2026-09-04 03:45:52 +08:00
2026-05-13 11:21:35 +08:00
cat <<EOF
2026-05-16 15:22:42 +08:00
Docker Compose single-node files are ready:
2026-05-13 11:21:35 +08:00
${COMPOSE_DIR}/docker-compose.yml
${COMPOSE_DIR}/.env
${COMPOSE_DIR}/.env.example
2026-05-20 22:02:47 +08:00
${COMPOSE_DIR}/update.sh
2026-05-13 11:21:35 +08:00
${COMPOSE_DIR}/generate_keys.sh
2026-05-23 20:14:26 +08:00
${COMPOSE_DIR}/logs
2026-05-13 11:21:35 +08:00
EOF
2026-05-15 18:43:47 +08:00
if [[ " ${ SKIP_START } " == "true" ]] ; then
compose_manual_start_steps
return
fi
require_compose_runtime
start_compose_deployment
compose_next_steps
2026-05-13 11:21:35 +08:00
}
2026-05-06 20:03:07 +08:00
install_env_file() {
local env_file = " $1 "
2026-05-12 15:36:29 +08:00
install_config_dir
2026-05-06 20:03:07 +08:00
if [[ -n " ${ env_file } " ]] ; then
info "installing env file to ${ ENV_TARGET } "
2026-05-12 15:36:29 +08:00
install_env_target_from " ${ env_file } "
else
ensure_env_target_permissions
2026-05-06 20:03:07 +08:00
fi
2026-09-04 03:45:52 +08:00
replace_or_append_env " ${ ENV_TARGET } " "AETHER_UPDATE_STRATEGY" "manual"
}
switch_current_release_link() {
local release_dir = " $1 "
local current_link = " $2 "
local next_link = " ${ current_link } .new"
[[ ! -e " ${ current_link } " || -L " ${ current_link } " ]] \
|| die "current release path exists and is not a symbolic link"
[[ ! -e " ${ next_link } " || -L " ${ next_link } " ]] \
|| die "temporary current release path exists and is not a symbolic link"
rm -f -- " ${ next_link } "
ln -s -- " ${ release_dir } " " ${ next_link } "
[[ -L " ${ next_link } " && " $( readlink " ${ next_link } " ) " == " ${ release_dir } " ]] \
|| die "could not create the temporary current release symbolic link"
# Recheck immediately before rename. The install root is root-owned and not
# writable by the service account, so an unprivileged process cannot race it.
[[ ! -e " ${ current_link } " || -L " ${ current_link } " ]] \
|| die "current release path changed to a non-symbolic-link"
case " $( install_os) " in
linux)
mv -fT -- " ${ next_link } " " ${ current_link } "
;;
macos)
mv -fh -- " ${ next_link } " " ${ current_link } "
;;
esac
}
select_release_install_directory() {
local requested = " $1 "
local selected
if [[ ! -e " ${ requested } " && ! -L " ${ requested } " ]] ; then
printf '%s\n' " ${ requested } "
return
fi
[[ -d " ${ requested } " && ! -L " ${ requested } " ]] \
|| die "release path exists and is not a managed directory: ${ requested } "
selected = " $( mktemp -d " ${ requested } .XXXXXXXX" ) " \
|| die "could not allocate an immutable release directory beside ${ requested } "
[[ -d " ${ selected } " && ! -L " ${ selected } " ]] \
|| die "release staging path is not a real directory: ${ selected } "
printf '%s\n' " ${ selected } "
2026-05-06 20:03:07 +08:00
}
install_release() {
local bundle = " $1 "
2026-09-04 03:45:52 +08:00
local release_dir
2026-05-06 20:03:07 +08:00
local current_link = " ${ INSTALL_ROOT } /current"
2026-09-04 03:45:52 +08:00
validate_release_identifier " ${ VERSION } "
release_dir = " ${ INSTALL_ROOT } /releases/ ${ VERSION } "
validate_local_bundle_tree " ${ bundle } "
2026-05-06 20:03:07 +08:00
2026-09-04 03:45:52 +08:00
[[ ! -L " ${ INSTALL_ROOT } " ]] || die "install root may not be a symbolic link: ${ INSTALL_ROOT } "
if [[ -e " ${ INSTALL_ROOT } " && ! -d " ${ INSTALL_ROOT } " ]] ; then
die "install root path is not a directory: ${ INSTALL_ROOT } "
fi
[[ ! -L " ${ INSTALL_ROOT } /releases" ]] \
|| die "releases directory may not be a symbolic link: ${ INSTALL_ROOT } /releases"
[[ ! -L " ${ INSTALL_ROOT } /data" ]] \
|| die "data directory may not be a symbolic link: ${ INSTALL_ROOT } /data"
[[ ! -L " ${ INSTALL_ROOT } /logs" ]] \
|| die "logs directory may not be a symbolic link: ${ INSTALL_ROOT } /logs"
install -d -o root -m 0755 " ${ INSTALL_ROOT } " " ${ INSTALL_ROOT } /releases"
[[ ! -L " ${ INSTALL_ROOT } " ]] || die "install root became a symbolic link: ${ INSTALL_ROOT } "
[[ ! -L " ${ INSTALL_ROOT } /releases" ]] \
|| die "releases directory became a symbolic link: ${ INSTALL_ROOT } /releases"
chmod 0755 " ${ INSTALL_ROOT } " " ${ INSTALL_ROOT } /releases"
2026-05-22 15:15:17 +08:00
install -d -m 0755 " ${ INSTALL_ROOT } /data" " ${ INSTALL_ROOT } /logs"
2026-09-04 03:45:52 +08:00
[[ ! -L " ${ INSTALL_ROOT } /data" && ! -L " ${ INSTALL_ROOT } /logs" ]] \
|| die "managed data or log directory became a symbolic link"
2026-05-12 15:36:29 +08:00
if is_darwin; then
install -d -o " ${ SERVICE_USER } " -g " ${ SERVICE_GROUP } " -m 0750 \
" ${ INSTALL_ROOT } /data" \
" ${ INSTALL_ROOT } /logs"
else
install -d -o " ${ SERVICE_USER } " -g " ${ SERVICE_GROUP } " -m 0750 \
" ${ INSTALL_ROOT } /data" \
" ${ INSTALL_ROOT } /logs"
fi
2026-09-04 03:45:52 +08:00
release_dir = " $( select_release_install_directory " ${ release_dir } " ) "
info "installing release ${ VERSION } into ${ release_dir } "
2026-05-06 20:03:07 +08:00
install -d -m 0755 " ${ release_dir } /bin" " ${ release_dir } /frontend"
2026-09-04 03:45:52 +08:00
cp -P " ${ bundle } /bin/aether-gateway" " ${ release_dir } /bin/aether-gateway"
cp -RP " ${ bundle } /frontend/." " ${ release_dir } /frontend/"
validate_local_bundle_tree " ${ release_dir } "
chmod -R u = rwX,go= rX " ${ release_dir } "
2026-05-12 15:36:29 +08:00
if is_darwin; then
2026-05-22 15:15:17 +08:00
chown -R root:" ${ SERVICE_GROUP } " " ${ release_dir } "
2026-05-12 15:36:29 +08:00
else
2026-05-22 15:15:17 +08:00
chown -R root:" ${ SERVICE_GROUP } " " ${ release_dir } "
2026-05-12 15:36:29 +08:00
fi
2026-09-04 03:45:52 +08:00
chmod -R u = rwX,go= rX " ${ release_dir } "
switch_current_release_link " ${ release_dir } " " ${ current_link } "
2026-05-06 20:03:07 +08:00
}
2026-05-12 15:36:29 +08:00
prune_old_releases() {
local keep = " ${ RELEASE_KEEP } "
[[ " ${ keep } " = ~ ^[ 0-9] +$ ]] || return 0
[[ " ${ keep } " -gt 0 ]] || return 0
local releases_dir = " ${ INSTALL_ROOT } /releases"
[[ -d " ${ releases_dir } " ]] || return 0
local current_target
current_target = " $( readlink " ${ INSTALL_ROOT } /current" 2>/dev/null || true ) "
current_target = " $( basename " ${ current_target } " 2>/dev/null || true ) "
2026-09-04 03:45:52 +08:00
local releases_dir_real
releases_dir_real = " $( cd -- " ${ releases_dir } " && pwd -P) "
local dir name parent_real
local -a safe_release_dirs =()
for dir in " ${ releases_dir } " /*; do
[[ -d " ${ dir } " && ! -L " ${ dir } " ]] || continue
name = " $( basename -- " ${ dir } " ) "
is_safe_release_identifier " ${ name } " || continue
parent_real = " $( cd -- " $( dirname -- " ${ dir } " ) " && pwd -P) "
[[ " ${ parent_real } " == " ${ releases_dir_real } " ]] || continue
2026-05-12 15:36:29 +08:00
[[ " ${ name } " != " ${ current_target } " ]] || continue
2026-09-04 03:45:52 +08:00
safe_release_dirs +=( " ${ dir } " )
done
local count = " ${# safe_release_dirs [@] } "
2026-05-12 15:36:29 +08:00
if [[ " ${ count } " -ge " ${ keep } " ]] ; then
local to_remove
2026-09-04 03:45:52 +08:00
to_remove = " $( ls -1dt -- " ${ safe_release_dirs [@] } " 2>/dev/null | tail -n +$(( keep))) "
2026-05-12 15:36:29 +08:00
local removed = 0
while IFS = read -r dir; do
[[ -n " ${ dir } " ]] || continue
2026-09-04 03:45:52 +08:00
[[ -d " ${ dir } " && ! -L " ${ dir } " ]] || continue
name = " $( basename -- " ${ dir } " ) "
is_safe_release_identifier " ${ name } " || continue
parent_real = " $( cd -- " $( dirname -- " ${ dir } " ) " && pwd -P) "
[[ " ${ parent_real } " == " ${ releases_dir_real } " ]] || continue
2026-05-12 15:36:29 +08:00
info "pruning old release: $( basename " ${ dir } " ) "
2026-09-04 03:45:52 +08:00
rm -rf -- " ${ dir } "
2026-05-12 15:36:29 +08:00
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
}
2026-05-06 20:03:07 +08:00
render_systemd_unit() {
cat <<EOF
[Unit]
Description=Aether Gateway
Documentation=https://github.com/${REPO}
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=${SERVICE_USER}
Group=${SERVICE_GROUP}
WorkingDirectory=${INSTALL_ROOT}/current
EnvironmentFile=${ENV_TARGET}
ExecStart=${INSTALL_ROOT}/current/bin/aether-gateway
Restart=on-failure
RestartSec=3
TimeoutStopSec=20
UMask=0027
LimitNOFILE=65535
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
EOF
}
install_systemd_unit() {
2026-09-04 03:45:52 +08:00
local rendered_unit unit_dir
2026-05-06 20:03:07 +08:00
rendered_unit = " $( mktemp) "
render_systemd_unit > " ${ rendered_unit } "
info "installing systemd unit to ${ SYSTEMD_UNIT_PATH } "
2026-09-04 03:45:52 +08:00
unit_dir = " $( dirname -- " ${ SYSTEMD_UNIT_PATH } " ) "
ensure_privileged_directory " ${ unit_dir } " 0755 root root
atomic_install_managed_file \
" ${ rendered_unit } " " ${ SYSTEMD_UNIT_PATH } " 0644 root root
rm -f -- " ${ rendered_unit } "
2026-05-06 20:03:07 +08:00
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
gateway_port = " $( awk -F= '/^[[:space:]]*APP_PORT=/{print $2}' " ${ ENV_TARGET } " | tail -n1 | tr -d '[:space:]' ) "
gateway_port = " ${ gateway_port :- 8084 } "
cat <<EOF
Install complete.
Gateway service:
sudo systemctl status ${SERVICE_NAME} --no-pager
sudo journalctl -u ${SERVICE_NAME} -n 100 --no-pager
sudo journalctl -u ${SERVICE_NAME} -f
Health checks:
curl -fsS http://127.0.0.1:${gateway_port}/_gateway/health
curl -fsS http://127.0.0.1:${gateway_port}/readyz
Install directory:
${INSTALL_ROOT}
data: ${INSTALL_ROOT}/data
logs: ${INSTALL_ROOT}/logs
EOF
cat <<EOF
Database:
2026-09-03 11:05:59 +08:00
schema migrations and data backfills are prepared automatically before startup
2026-05-06 20:03:07 +08:00
Current release:
${INSTALL_ROOT}/current
EOF
}
2026-05-12 15:36:29 +08:00
launchd_wrapper_path() {
printf '%s/bin/%s-launchd\n' " ${ INSTALL_ROOT } " " ${ SERVICE_NAME } "
}
install_launchd_support_files() {
install_config_dir
write_generate_keys_script " ${ CONFIG_DIR } /generate_keys.sh"
}
write_launchd_wrapper() {
2026-09-04 03:45:52 +08:00
local wrapper wrapper_dir rendered
2026-05-12 15:36:29 +08:00
wrapper = " $( launchd_wrapper_path) "
2026-09-04 03:45:52 +08:00
wrapper_dir = " $( dirname -- " ${ wrapper } " ) "
ensure_privileged_directory " ${ wrapper_dir } " 0755 root wheel
rendered = " $( mktemp) "
{
cat <<'EOF'
2026-05-12 15:36:29 +08:00
#!/usr/bin/env bash
set -euo pipefail
EOF
2026-09-04 03:45:52 +08:00
printf 'ENV_TARGET=%q\n' " ${ ENV_TARGET } "
printf 'AETHER_BIN=%q\n' " ${ INSTALL_ROOT } /current/bin/aether-gateway"
cat <<'EOF'
2026-05-12 15:36:29 +08:00
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
2026-09-04 03:45:52 +08:00
} > " ${ rendered } "
atomic_install_managed_file " ${ rendered } " " ${ wrapper } " 0755 root wheel
rm -f -- " ${ rendered } "
}
xml_escape() {
local value = " $1 "
value = " ${ value //&/& } "
value = " ${ value //</< } "
value = " ${ value //>/> } "
value = " ${ value // \" /" } "
value = " ${ value // \' /' } "
printf '%s' " ${ value } "
2026-05-12 15:36:29 +08:00
}
render_launchd_plist() {
2026-09-04 03:45:52 +08:00
local wrapper label service_user service_group working_directory stdout_path stderr_path
wrapper = " $( xml_escape " $( launchd_wrapper_path) " ) "
label = " $( xml_escape " ${ LAUNCHD_LABEL } " ) "
service_user = " $( xml_escape " ${ SERVICE_USER } " ) "
service_group = " $( xml_escape " ${ SERVICE_GROUP } " ) "
working_directory = " $( xml_escape " ${ INSTALL_ROOT } /current" ) "
stdout_path = " $( xml_escape " ${ LAUNCHD_LOG_DIR } / ${ SERVICE_NAME } .out.log" ) "
stderr_path = " $( xml_escape " ${ LAUNCHD_LOG_DIR } / ${ SERVICE_NAME } .err.log" ) "
2026-05-12 15:36:29 +08:00
cat <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
2026-09-04 03:45:52 +08:00
<string>${label}</string>
2026-05-12 15:36:29 +08:00
<key>ProgramArguments</key>
<array>
<string>${wrapper}</string>
</array>
<key>UserName</key>
2026-09-04 03:45:52 +08:00
<string>${service_user}</string>
2026-05-12 15:36:29 +08:00
<key>GroupName</key>
2026-09-04 03:45:52 +08:00
<string>${service_group}</string>
2026-05-12 15:36:29 +08:00
<key>WorkingDirectory</key>
2026-09-04 03:45:52 +08:00
<string>${working_directory}</string>
2026-05-12 15:36:29 +08:00
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
2026-09-04 03:45:52 +08:00
<string>${stdout_path}</string>
2026-05-12 15:36:29 +08:00
<key>StandardErrorPath</key>
2026-09-04 03:45:52 +08:00
<string>${stderr_path}</string>
2026-05-12 15:36:29 +08:00
<key>Umask</key>
<integer>23</integer>
</dict>
</plist>
EOF
}
install_launchd_log_files() {
2026-09-04 03:45:52 +08:00
local path staged
local -a log_paths =(
" ${ LAUNCHD_LOG_DIR } / ${ SERVICE_NAME } .out.log"
" ${ LAUNCHD_LOG_DIR } / ${ SERVICE_NAME } .err.log"
)
ensure_privileged_directory " ${ LAUNCHD_LOG_DIR } " 0755 root wheel
for path in " ${ log_paths [@] } " ; do
validate_managed_regular_file " ${ path } " false
done
for path in " ${ log_paths [@] } " ; do
if [[ ! -e " ${ path } " ]] ; then
staged = " $( mktemp) "
atomic_install_managed_file \
" ${ staged } " " ${ path } " 0640 " ${ SERVICE_USER } " " ${ SERVICE_GROUP } "
rm -f -- " ${ staged } "
else
chown " ${ SERVICE_USER } : ${ SERVICE_GROUP } " " ${ path } "
chmod 0640 " ${ path } "
validate_managed_regular_file " ${ path } " false
fi
done
2026-05-12 15:36:29 +08:00
}
install_launchd_unit() {
2026-09-04 03:45:52 +08:00
local rendered_plist plist_dir
2026-05-12 15:36:29 +08:00
rendered_plist = " $( mktemp) "
render_launchd_plist > " ${ rendered_plist } "
info "installing launchd plist to ${ LAUNCHD_PLIST_PATH } "
2026-09-04 03:45:52 +08:00
plist_dir = " $( dirname -- " ${ LAUNCHD_PLIST_PATH } " ) "
ensure_privileged_directory " ${ plist_dir } " 0755 root wheel
validate_managed_regular_file " ${ LAUNCHD_PLIST_PATH } " true
2026-05-12 15:36:29 +08:00
install_launchd_log_files
2026-09-04 03:45:52 +08:00
atomic_install_managed_file \
" ${ rendered_plist } " " ${ LAUNCHD_PLIST_PATH } " 0644 root wheel
rm -f -- " ${ rendered_plist } "
2026-05-12 15:36:29 +08:00
}
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
gateway_port = " $( awk -F= '/^[[:space:]]*APP_PORT=/{print $2}' " ${ ENV_TARGET } " | tail -n1 | tr -d '[:space:]' ) "
gateway_port = " ${ gateway_port :- 8084 } "
cat <<EOF
Install complete.
Gateway service:
sudo launchctl print system/${LAUNCHD_LABEL}
sudo launchctl kickstart -k system/${LAUNCHD_LABEL}
sudo launchctl bootout system ${LAUNCHD_PLIST_PATH}
Logs:
tail -f ${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.out.log ${LAUNCHD_LOG_DIR}/${SERVICE_NAME}.err.log
Health checks:
curl -fsS http://127.0.0.1:${gateway_port}/_gateway/health
curl -fsS http://127.0.0.1:${gateway_port}/readyz
Install directory:
${INSTALL_ROOT}
data: ${INSTALL_ROOT}/data
logs: ${INSTALL_ROOT}/logs
EOF
cat <<EOF
Database:
2026-09-03 11:05:59 +08:00
schema migrations and data backfills are prepared automatically before startup
2026-05-12 15:36:29 +08:00
Current release:
${INSTALL_ROOT}/current
EOF
}
2026-05-06 20:03:07 +08:00
install_systemd_mode() {
local bundle = " $1 "
local env_file = " $2 "
ensure_service_account
install_systemd_support_files
install_env_file " ${ env_file } "
validate_env_file " ${ ENV_TARGET } "
install_release " ${ bundle } "
2026-05-12 15:36:29 +08:00
prune_old_releases
2026-05-06 20:03:07 +08:00
install_systemd_unit
restart_service_if_requested
print_systemd_next_steps
}
2026-05-12 15:36:29 +08:00
install_launchd_mode() {
local bundle = " $1 "
local env_file = " $2 "
ensure_macos_service_account
install_launchd_support_files
install_env_file " ${ env_file } "
validate_env_file " ${ ENV_TARGET } "
install_release " ${ bundle } "
prune_old_releases
write_launchd_wrapper
install_launchd_unit
restart_launchd_if_requested
print_launchd_next_steps
}
2026-05-06 20:03:07 +08:00
main() {
local bundle env_file
parse_args " $@ "
2026-05-07 18:53:09 +08:00
select_language
2026-05-12 15:36:29 +08:00
require_supported_os
apply_platform_defaults
2026-05-06 20:03:07 +08:00
select_version
2026-09-04 03:45:52 +08:00
validate_installer_source_identifiers
2026-05-06 20:03:07 +08:00
select_mode
if [[ " ${ MODE } " == "compose" ]] ; then
2026-09-04 03:45:52 +08:00
resolve_compose_release_identity
2026-05-06 20:03:07 +08:00
install_compose_mode
2026-05-16 15:22:42 +08:00
elif [[ " ${ MODE } " == "compose-single-node" ]] ; then
2026-09-04 03:45:52 +08:00
resolve_compose_release_identity
2026-05-16 15:22:42 +08:00
install_compose_single_node_mode
2026-05-06 20:03:07 +08:00
else
require_root
2026-05-12 15:36:29 +08:00
require_service_manager
2026-09-04 03:45:52 +08:00
validate_single_node_managed_paths
2026-05-06 20:03:07 +08:00
bundle = " $( local_bundle_dir || true ) "
if [[ -z " ${ bundle } " ]] ; then
2026-05-12 15:36:29 +08:00
download_or_unpack_bundle
bundle = " ${ BUNDLE_DIR } "
2026-05-06 20:03:07 +08:00
else
2026-09-04 03:45:52 +08:00
validate_local_bundle_tree " ${ bundle } "
2026-05-06 20:03:07 +08:00
if [[ -z " ${ VERSION } " ]] ; then
VERSION = " $( derive_local_bundle_version " ${ bundle } " ) "
fi
2026-09-04 03:45:52 +08:00
validate_release_identifier " ${ VERSION } "
2026-05-06 20:03:07 +08:00
info "installing from local extracted bundle ${ bundle } "
fi
2026-05-12 15:36:29 +08:00
if is_darwin; then
ensure_macos_service_account
fi
env_file = " $( resolve_service_env_source " ${ MODE } " ) "
case " $( install_os) " in
linux)
install_systemd_mode " ${ bundle } " " ${ env_file } "
;;
macos)
install_launchd_mode " ${ bundle } " " ${ env_file } "
;;
esac
2026-05-06 20:03:07 +08:00
fi
if [[ -n " ${ ADMIN_PASSWORD_SOURCE } " ]] ; then
local password_note
if [[ " ${ ADMIN_PASSWORD_SOURCE } " == "prompt" ]] ; then
password_note = "set from prompt"
else
password_note = "set from ADMIN_PASSWORD"
fi
cat <<EOF
Initial admin:
username: admin
password: ${password_note}
The password is stored in the generated env file. Change it after first login.
EOF
fi
}
2026-09-04 03:45:52 +08:00
if [[ " ${ BASH_SOURCE [0] :- $0 } " == " $0 " ]] ; then
main " $@ "
fi