feat: add signed PostgreSQL release updates

This commit is contained in:
Qiufeng
2026-08-03 23:45:32 +08:00
parent 2524a37a07
commit f6e22cb670
99 changed files with 44671 additions and 284 deletions
Executable
+525
View File
@@ -0,0 +1,525 @@
#!/usr/bin/env bash
set -euo pipefail
GITEA_BASE_URL="${ERP_GITEA_BASE_URL:-}"
REPOSITORY="${ERP_UPDATE_REPOSITORY:-awaioi/ERP}"
REQUESTED_VERSION="${ERP_INSTALL_VERSION:-}"
INSTALL_ROOT="${ERP_INSTALL_ROOT:-}"
DB_MODE="${ERP_DB_MODE:-existing}"
NON_INTERACTIVE="${ERP_INSTALL_NON_INTERACTIVE:-0}"
ALLOW_INSECURE="${ERP_UPDATE_ALLOW_INSECURE_HTTP:-0}"
NO_SERVICE="${ERP_INSTALL_NO_SERVICE:-0}"
TOKEN="${ERP_GITEA_TOKEN:-${OA_UPDATE_TOKEN:-}}"
PUBLIC_KEY='-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAaErhcY8WZIZvPILmYnfjndVBAdOuWkvhaoHIWqNNdxI=
-----END PUBLIC KEY-----'
TMP_DIR=""
say() { printf '[ERP Install] %s\n' "$*"; }
fail() { printf '[ERP Install] ERROR: %s\n' "$*" >&2; exit 1; }
usage() {
cat <<'EOF'
Usage: install.sh [options]
--gitea-url URL Gitea public base URL
--repository O/R Release repository
--version VERSION Install one exact release
--install-root PATH Override installation directory
--db-mode MODE existing or local (local is Linux only)
--non-interactive Read all database values from ERP_DB_* variables
--allow-insecure Development only: allow plain HTTP release URLs
--no-service Install files without registering/starting a service
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--gitea-url) [[ $# -ge 2 ]] || fail '--gitea-url requires a value'; GITEA_BASE_URL="$2"; shift 2 ;;
--repository) [[ $# -ge 2 ]] || fail '--repository requires a value'; REPOSITORY="$2"; shift 2 ;;
--version) [[ $# -ge 2 ]] || fail '--version requires a value'; REQUESTED_VERSION="$2"; shift 2 ;;
--install-root) [[ $# -ge 2 ]] || fail '--install-root requires a value'; INSTALL_ROOT="$2"; shift 2 ;;
--db-mode) [[ $# -ge 2 ]] || fail '--db-mode requires a value'; DB_MODE="$2"; shift 2 ;;
--non-interactive) NON_INTERACTIVE=1; shift ;;
--allow-insecure) ALLOW_INSECURE=1; shift ;;
--no-service) NO_SERVICE=1; shift ;;
-h|--help) usage; exit 0 ;;
*) fail "unknown option: $1" ;;
esac
done
cleanup() {
if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then
rm -rf "$TMP_DIR"
fi
}
trap cleanup EXIT
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "missing command: $1"
}
detect_platform() {
case "$(uname -s)" in
Linux) PLATFORM=linux ;;
Darwin) PLATFORM=darwin ;;
*) fail "unsupported operating system: $(uname -s)" ;;
esac
case "$(uname -m)" in
x86_64|amd64) ARCH=amd64 ;;
arm64|aarch64) ARCH=arm64 ;;
*) fail "unsupported CPU architecture: $(uname -m); 32-bit systems are not supported" ;;
esac
if [[ "$PLATFORM" == "linux" && "$(id -u)" != "0" ]]; then
fail 'Linux installation requires root; use: curl ... | sudo -E bash'
fi
}
install_dependencies() {
if [[ "$PLATFORM" == "darwin" ]] && command -v brew >/dev/null 2>&1; then
local java_prefix="" libpq_prefix="" openssl_prefix=""
java_prefix="$(brew --prefix openjdk@17 2>/dev/null || true)"
libpq_prefix="$(brew --prefix libpq 2>/dev/null || true)"
openssl_prefix="$(brew --prefix openssl@3 2>/dev/null || true)"
[[ -z "$java_prefix" ]] || export PATH="$java_prefix/bin:$PATH"
[[ -z "$libpq_prefix" ]] || export PATH="$libpq_prefix/bin:$PATH"
[[ -z "$openssl_prefix" ]] || export PATH="$openssl_prefix/bin:$PATH"
fi
local need=0
for command in curl tar python3 psql openssl java; do
command -v "$command" >/dev/null 2>&1 || need=1
done
if [[ "$need" == "1" && "$PLATFORM" == "linux" ]]; then
command -v apt-get >/dev/null 2>&1 || fail 'automatic dependency installation currently supports apt-based Linux only'
say 'Installing runtime dependencies...'
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y ca-certificates curl tar python3 openssl openjdk-17-jre-headless postgresql-client
elif [[ "$need" == "1" ]]; then
command -v brew >/dev/null 2>&1 || fail 'Homebrew is required to install missing macOS dependencies'
say 'Installing runtime dependencies...'
brew install openjdk@17 libpq openssl@3
export PATH="$(brew --prefix openjdk@17)/bin:$(brew --prefix libpq)/bin:$(brew --prefix openssl@3)/bin:$PATH"
fi
for command in curl tar python3 psql openssl java; do require_command "$command"; done
local java_major
java_major="$(java -version 2>&1 | awk -F'[".]' '/version/ {print $2; exit}')"
[[ "$java_major" =~ ^[0-9]+$ && "$java_major" -ge 17 ]] || fail 'Java 17 or newer is required'
local openssl_version algorithms
openssl_version="$(openssl version 2>/dev/null || true)"
[[ "$openssl_version" =~ ^OpenSSL[[:space:]]3\. ]] \
|| fail 'OpenSSL 3 with Ed25519 support is required'
algorithms="$(openssl list -public-key-algorithms 2>/dev/null || true)"
grep -qi ED25519 <<< "$algorithms" \
|| fail 'OpenSSL 3 with Ed25519 support is required'
}
validate_https_url() {
case "$1" in
https://*) return 0 ;;
http://*) [[ "$ALLOW_INSECURE" == "1" || "$ALLOW_INSECURE" == "true" ]] && return 0 ;;
esac
fail 'release server must use HTTPS (use --allow-insecure only for local development)'
}
prompt_value() {
local variable="$1" label="$2" default="$3" secret="${4:-0}" value="${!variable:-}"
if [[ -z "$value" && "$NON_INTERACTIVE" != "1" ]]; then
[[ -r /dev/tty && -w /dev/tty ]] \
|| fail "cannot prompt for $label; use --non-interactive with ERP_DB_* variables"
if [[ "$secret" == "1" ]]; then
IFS= read -r -s -p "$label: " value </dev/tty \
|| fail "cannot read $label; use --non-interactive with ERP_DB_* variables"
printf '\n' >/dev/tty
else
IFS= read -r -p "$label [$default]: " value </dev/tty \
|| fail "cannot read $label; use --non-interactive with ERP_DB_* variables"
fi
fi
value="${value:-$default}"
printf -v "$variable" '%s' "$value"
}
run_as_postgres() {
if [[ "$(id -un)" == "postgres" ]]; then
psql "$@"
elif command -v runuser >/dev/null 2>&1; then
runuser -u postgres -- psql "$@"
elif command -v sudo >/dev/null 2>&1; then
sudo -u postgres -- psql "$@"
else
fail 'cannot switch to the postgres operating-system user (runuser or sudo is required)'
fi
}
configure_local_postgres() {
[[ "$PLATFORM" == "linux" ]] || fail 'automatic local PostgreSQL provisioning is currently Linux-only'
command -v apt-get >/dev/null 2>&1 || fail 'local PostgreSQL provisioning requires apt'
require_command systemctl
apt-get update
apt-get install -y postgresql postgresql-contrib
systemctl enable --now postgresql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_NAME="${ERP_DB_NAME:-kaidi_erp}"
DB_USER="${ERP_DB_USER:-kaidi_erp}"
DB_PASSWORD="${ERP_DB_PASSWORD:-}"
if [[ -z "$DB_PASSWORD" ]]; then
DB_PASSWORD="$(openssl rand -hex 24)" || fail 'unable to generate a PostgreSQL password'
fi
DB_SSLMODE=disable
[[ "$DB_NAME" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]] || fail 'invalid local database name'
[[ "$DB_USER" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]] || fail 'invalid local database user'
run_as_postgres -v ON_ERROR_STOP=1 -v role="$DB_USER" -v password="$DB_PASSWORD" <<'SQL'
SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'role', :'password')
WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'role') \gexec
SELECT format('ALTER ROLE %I LOGIN PASSWORD %L', :'role', :'password') \gexec
SQL
run_as_postgres -v ON_ERROR_STOP=1 -v db="$DB_NAME" -v role="$DB_USER" <<'SQL'
SELECT format('CREATE DATABASE %I OWNER %I', :'db', :'role')
WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = :'db') \gexec
SQL
}
configure_existing_postgres() {
prompt_value DB_HOST 'PostgreSQL host' "${ERP_DB_HOST:-127.0.0.1}"
prompt_value DB_PORT 'PostgreSQL port' "${ERP_DB_PORT:-5432}"
prompt_value DB_NAME 'Database name' "${ERP_DB_NAME:-kaidi_erp}"
prompt_value DB_USER 'Database user' "${ERP_DB_USER:-kaidi_erp}"
prompt_value DB_PASSWORD 'Database password' "${ERP_DB_PASSWORD:-}" 1
prompt_value DB_SSLMODE 'PostgreSQL SSL mode' "${ERP_DB_SSLMODE:-prefer}"
[[ -n "$DB_PASSWORD" ]] || fail 'database password is required'
}
validate_postgres() {
[[ "$DB_HOST" != *[[:space:]]* && "$DB_PORT" =~ ^[0-9]+$ ]] || fail 'invalid PostgreSQL host or port'
[[ "$DB_NAME" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]] || fail 'invalid database name'
[[ "$DB_USER" =~ ^[A-Za-z_][A-Za-z0-9_.-]*$ ]] || fail 'invalid database user'
case "$DB_SSLMODE" in disable|allow|prefer|require|verify-ca|verify-full) ;; *) fail 'invalid PostgreSQL SSL mode' ;; esac
local version
version="$(PGPASSWORD="$DB_PASSWORD" PGSSLMODE="$DB_SSLMODE" psql \
-h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -Atqc \
"select current_setting('server_version_num')::int")" \
|| fail 'cannot connect to PostgreSQL with the supplied credentials'
[[ "$version" =~ ^[0-9]+$ && "$version" -ge 150000 ]] || fail 'PostgreSQL 15 or newer is required'
PGPASSWORD="$DB_PASSWORD" PGSSLMODE="$DB_SSLMODE" psql \
-h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 \
-c 'CREATE EXTENSION IF NOT EXISTS pg_trgm' >/dev/null \
|| fail 'database user cannot install the required pg_trgm extension'
say "PostgreSQL connection verified (${version:0:2})"
}
create_curl_config() {
CURL_CONFIG="$TMP_DIR/curl.conf"
{
printf 'silent\nshow-error\nfail\nlocation\nconnect-timeout = 15\nmax-time = 900\n'
if [[ "$ALLOW_INSECURE" == "1" || "$ALLOW_INSECURE" == "true" ]]; then
printf 'proto = "=http,https"\nproto-redir = "=http,https"\n'
else
printf 'proto = "=https"\nproto-redir = "=https"\n'
fi
if [[ -n "$TOKEN" ]]; then
[[ "$TOKEN" =~ ^[A-Za-z0-9._-]+$ ]] || fail 'invalid Gitea token format'
printf 'header = "Authorization: token %s"\n' "$TOKEN"
fi
} > "$CURL_CONFIG"
chmod 600 "$CURL_CONFIG"
}
download() {
validate_https_url "$1"
curl --config "$CURL_CONFIG" --output "$2" "$1"
}
download_release() {
[[ "$REPOSITORY" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || fail 'invalid Gitea repository'
local owner="${REPOSITORY%%/*}" repo="${REPOSITORY#*/}"
local release_json="$TMP_DIR/release.json"
download "${GITEA_BASE_URL%/}/api/v1/repos/$owner/$repo/releases/latest" "$release_json" \
|| fail 'unable to read latest Gitea Release'
local selection="$TMP_DIR/selection"
local selection_error="$TMP_DIR/selection-error"
if ! python3 - "$release_json" "$REQUESTED_VERSION" > "$selection" 2> "$selection_error" <<'PY'
import json, re, sys
try:
with open(sys.argv[1], encoding="utf-8") as handle:
release = json.load(handle)
except (OSError, json.JSONDecodeError):
raise SystemExit("invalid release JSON")
tag = str(release.get("tag_name") or "").strip()
match = re.fullmatch(r"v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)", tag)
if not match or release.get("draft") or release.get("prerelease"):
raise SystemExit("invalid stable release")
version = match.group(1)
if "-" in version.split("+", 1)[0]:
raise SystemExit("prerelease rejected by stable installer")
requested = sys.argv[2].removeprefix("v")
if requested and requested != version:
raise SystemExit("requested version does not match latest release")
assets = {str(a.get("name")): str(a.get("browser_download_url") or "") for a in release.get("assets", [])}
names = [f"kaidi-erp-{version}.tar.gz", "SHA256SUMS", "SHA256SUMS.sig"]
urls = [assets.get(name, "") for name in names]
if any(not url or any(ch.isspace() for ch in url) for url in urls):
raise SystemExit("release assets missing")
print("\t".join([version, *urls]))
PY
then
local reason="invalid Release metadata"
[[ ! -s "$selection_error" ]] || reason="$(<"$selection_error")"
fail "$reason"
fi
local archive_url sums_url signature_url
IFS=$'\t' read -r VERSION archive_url sums_url signature_url < "$selection"
ARCHIVE_NAME="kaidi-erp-${VERSION}.tar.gz"
ARCHIVE_PATH="$TMP_DIR/$ARCHIVE_NAME"
download "$archive_url" "$ARCHIVE_PATH"
download "$sums_url" "$TMP_DIR/SHA256SUMS"
download "$signature_url" "$TMP_DIR/SHA256SUMS.sig"
}
verify_release() {
printf '%s\n' "$PUBLIC_KEY" > "$TMP_DIR/release-public-key.pem"
openssl pkeyutl -verify -rawin -pubin -inkey "$TMP_DIR/release-public-key.pem" \
-sigfile "$TMP_DIR/SHA256SUMS.sig" -in "$TMP_DIR/SHA256SUMS" >/dev/null \
|| fail 'Release Ed25519 signature verification failed'
local expected actual
expected="$(python3 - "$TMP_DIR/SHA256SUMS" "$ARCHIVE_NAME" <<'PY'
import re, sys
for line in open(sys.argv[1], encoding="utf-8"):
m = re.fullmatch(r"([0-9a-fA-F]{64})\s+\*?(.+?)\s*", line)
if m and m.group(2) == sys.argv[2]:
print(m.group(1).lower())
break
PY
)"
[[ "$expected" =~ ^[0-9a-f]{64}$ ]] || fail 'release checksum is missing'
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$ARCHIVE_PATH" | awk '{print $1}')"
else
actual="$(shasum -a 256 "$ARCHIVE_PATH" | awk '{print $1}')"
fi
[[ "$actual" == "$expected" ]] || fail 'release checksum verification failed'
python3 - "$ARCHIVE_PATH" <<'PY'
import pathlib, sys, tarfile
with tarfile.open(sys.argv[1], "r:gz") as archive:
for item in archive.getmembers():
path = pathlib.PurePosixPath(item.name)
if path.is_absolute() or ".." in path.parts or item.issym() or item.islnk():
raise SystemExit(f"unsafe archive member: {item.name}")
PY
}
shell_setting() {
printf '%s=' "$1"
printf '%q\n' "$2"
}
prepare_layout() {
if [[ "$PLATFORM" == "linux" ]]; then
ERP_USER="${ERP_SERVICE_USER:-kaidi-erp}"
ERP_GROUP="$ERP_USER"
id "$ERP_USER" >/dev/null 2>&1 || useradd --system --home-dir "$INSTALL_ROOT" --shell /usr/sbin/nologin "$ERP_USER"
CONFIG_ROOT="${ERP_CONFIG_ROOT:-/etc/kaidi-erp}"
STATE_ROOT="${ERP_STATE_ROOT:-/var/lib/kaidi-erp}"
LOG_ROOT="${ERP_LOG_ROOT:-/var/log/kaidi-erp}"
local path
for path in "$INSTALL_ROOT" "$CONFIG_ROOT" "$STATE_ROOT" "$LOG_ROOT"; do
[[ "$path" =~ ^/[A-Za-z0-9._/@:+-]+$ ]] \
|| fail "Linux installation paths must be absolute and contain only safe characters: $path"
done
[[ "$ERP_USER" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]] || fail 'invalid Linux service user name'
else
ERP_USER="$(id -un)"
ERP_GROUP="$(id -gn)"
CONFIG_ROOT="${ERP_CONFIG_ROOT:-$INSTALL_ROOT/config}"
STATE_ROOT="${ERP_STATE_ROOT:-$INSTALL_ROOT/state}"
LOG_ROOT="${ERP_LOG_ROOT:-$HOME/Library/Logs/KaidiERP}"
fi
mkdir -p "$INSTALL_ROOT/releases" "$INSTALL_ROOT/run" "$INSTALL_ROOT/backups" \
"$CONFIG_ROOT" "$STATE_ROOT" "$LOG_ROOT"
if [[ "$PLATFORM" == "linux" ]]; then
chown -R "$ERP_USER:$ERP_GROUP" "$INSTALL_ROOT" "$STATE_ROOT" "$LOG_ROOT"
fi
}
install_release_files() {
local unpack="$TMP_DIR/unpack"
mkdir -p "$unpack"
tar -xzf "$ARCHIVE_PATH" -C "$unpack"
local source="$unpack/kaidi-erp-${VERSION}"
[[ -r "$source/app/kaidi-erp.jar" && -x "$source/bin/erp-run" && -x "$source/bin/erp-update" ]] \
|| fail 'release archive is incomplete'
[[ "$(<"$source/VERSION")" == "$VERSION" ]] || fail 'release archive version mismatch'
local destination="$INSTALL_ROOT/releases/$VERSION"
local replaced=""
if [[ -e "$destination" ]]; then
replaced="${destination}.replaced-$$"
mv "$destination" "$replaced"
fi
if ! mv "$source" "$destination"; then
[[ -z "$replaced" || ! -e "$replaced" ]] || mv "$replaced" "$destination"
fail 'unable to write the release directory'
fi
[[ -z "$replaced" ]] || rm -rf "$replaced"
python3 - "$INSTALL_ROOT/current" "releases/$VERSION" <<'PY'
import os, sys
link, target = sys.argv[1:]
tmp = f"{link}.new-{os.getpid()}"
try:
os.symlink(target, tmp)
os.replace(tmp, link)
finally:
if os.path.lexists(tmp): os.unlink(tmp)
PY
if [[ "$PLATFORM" == "linux" ]]; then
chown -R "$ERP_USER:$ERP_GROUP" "$destination" "$INSTALL_ROOT/current"
fi
}
write_configuration() {
CONFIG_FILE="$CONFIG_ROOT/erp.env"
PUBLIC_KEY_FILE="$CONFIG_ROOT/release-public-key.pem"
local update_enabled=true
[[ "$NO_SERVICE" != "1" ]] || update_enabled=false
printf '%s\n' "$PUBLIC_KEY" > "$PUBLIC_KEY_FILE"
{
shell_setting ERP_INSTALL_ROOT "$INSTALL_ROOT"
shell_setting ERP_CONFIG_FILE "$CONFIG_FILE"
shell_setting ERP_RUN_DIR "$INSTALL_ROOT/run"
shell_setting ERP_STATE_FILE "$STATE_ROOT/update-state.json"
shell_setting ERP_HEALTH_URL "http://127.0.0.1:${ERP_SERVER_PORT:-8091}/api/oa/health"
shell_setting ERP_UPDATE_PUBLIC_KEY_FILE "$PUBLIC_KEY_FILE"
shell_setting ERP_UPDATE_REQUIRE_SIGNATURE true
shell_setting ERP_UPDATE_BACKUP_MODE "${ERP_UPDATE_BACKUP_MODE:-none}"
shell_setting ERP_UPDATE_HEALTH_TIMEOUT_SECONDS "${ERP_UPDATE_HEALTH_TIMEOUT_SECONDS:-120}"
shell_setting ERP_UPDATE_HEALTH_POLL_SECONDS "${ERP_UPDATE_HEALTH_POLL_SECONDS:-2}"
shell_setting ERP_PGHOST "$DB_HOST"
shell_setting ERP_PGPORT "$DB_PORT"
shell_setting ERP_PGDATABASE "$DB_NAME"
shell_setting ERP_PGSSLMODE "$DB_SSLMODE"
shell_setting SPRING_PROFILES_ACTIVE postgres
shell_setting SERVER_PORT "${ERP_SERVER_PORT:-8091}"
shell_setting OA_DB_URL "jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}"
shell_setting OA_DB_USERNAME "$DB_USER"
shell_setting OA_DB_PASSWORD "$DB_PASSWORD"
shell_setting OA_UPDATE_ENABLED "$update_enabled"
shell_setting OA_UPDATE_GITEA_BASE_URL "$GITEA_BASE_URL"
shell_setting OA_UPDATE_REPOSITORY "$REPOSITORY"
shell_setting OA_UPDATE_CHANNEL stable
shell_setting OA_UPDATE_TOKEN "$TOKEN"
shell_setting OA_UPDATE_HELPER_COMMAND "$INSTALL_ROOT/current/bin/erp-update"
shell_setting OA_UPDATE_STATE_FILE "$STATE_ROOT/update-state.json"
shell_setting OA_UPDATE_ALLOW_INSECURE_HTTP "$ALLOW_INSECURE"
} > "$CONFIG_FILE"
if [[ "$PLATFORM" == "linux" ]]; then
chown root:"$ERP_GROUP" "$CONFIG_FILE" "$PUBLIC_KEY_FILE"
chmod 640 "$CONFIG_FILE" "$PUBLIC_KEY_FILE"
else
chmod 600 "$CONFIG_FILE" "$PUBLIC_KEY_FILE"
fi
}
install_service() {
[[ "$NO_SERVICE" == "1" ]] && return 0
if [[ "$PLATFORM" == "linux" ]]; then
cat > /etc/systemd/system/kaidi-erp.service <<EOF
[Unit]
Description=Kaidi ERP
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=$ERP_USER
Group=$ERP_GROUP
Environment="ERP_INSTALL_ROOT=$INSTALL_ROOT"
Environment="ERP_CONFIG_FILE=$CONFIG_FILE"
WorkingDirectory="$INSTALL_ROOT"
ExecStart="$INSTALL_ROOT/current/bin/erp-run"
Restart=always
RestartSec=3
TimeoutStopSec=90
SuccessExitStatus=143
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths="$INSTALL_ROOT" "$STATE_ROOT"
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now kaidi-erp.service
else
local agents="$HOME/Library/LaunchAgents"
local plist="$agents/com.kaidi.erp.plist"
mkdir -p "$agents"
python3 - "$plist" "$INSTALL_ROOT" "$CONFIG_FILE" "$LOG_ROOT" <<'PY'
import plistlib, sys
path, root, config, logs = sys.argv[1:]
payload = {
"Label": "com.kaidi.erp",
"ProgramArguments": [f"{root}/current/bin/erp-run"],
"EnvironmentVariables": {"ERP_INSTALL_ROOT": root, "ERP_CONFIG_FILE": config},
"WorkingDirectory": root,
"RunAtLoad": True,
"KeepAlive": True,
"ThrottleInterval": 3,
"StandardOutPath": f"{logs}/erp.log",
"StandardErrorPath": f"{logs}/erp-error.log",
}
with open(path, "wb") as handle:
plistlib.dump(payload, handle)
PY
launchctl bootout "gui/$(id -u)/com.kaidi.erp" >/dev/null 2>&1 || true
launchctl bootstrap "gui/$(id -u)" "$plist"
fi
}
wait_for_health() {
[[ "$NO_SERVICE" == "1" ]] && return 0
local url="http://127.0.0.1:${ERP_SERVER_PORT:-8091}/api/oa/health" deadline=$((SECONDS + 120))
while (( SECONDS < deadline )); do
if curl -fsS --connect-timeout 2 --max-time 5 "$url" >/dev/null 2>&1; then
return 0
fi
sleep 2
done
fail 'ERP service did not become healthy within 120 seconds'
}
main() {
detect_platform
if [[ -z "$INSTALL_ROOT" ]]; then
if [[ "$PLATFORM" == "linux" ]]; then
INSTALL_ROOT=/opt/kaidi-erp
else
INSTALL_ROOT="$HOME/Library/Application Support/KaidiERP"
fi
fi
[[ -n "$GITEA_BASE_URL" ]] || fail 'Gitea URL is required; use --gitea-url or ERP_GITEA_BASE_URL'
validate_https_url "$GITEA_BASE_URL"
install_dependencies
case "$DB_MODE" in
existing) configure_existing_postgres ;;
local) configure_local_postgres ;;
*) fail 'db mode must be existing or local' ;;
esac
validate_postgres
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kaidi-erp-install.XXXXXX")"
create_curl_config
say 'Downloading signed release...'
download_release
verify_release
prepare_layout
install_release_files
write_configuration
install_service
wait_for_health
say "Installed Kaidi ERP $VERSION"
say "Local URL: http://127.0.0.1:${ERP_SERVER_PORT:-8091}"
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi