475 lines
16 KiB
Bash
Executable File
475 lines
16 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
|
||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
|
||
INSTALL_ROOT="${ERP_INSTALL_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd -P)}"
|
||
CONFIG_FILE="${ERP_CONFIG_FILE:-$INSTALL_ROOT/config/erp.env}"
|
||
|
||
if [[ ! -r "$CONFIG_FILE" ]]; then
|
||
printf '[ERP Update] configuration not readable: %s\n' "$CONFIG_FILE" >&2
|
||
exit 1
|
||
fi
|
||
|
||
set -a
|
||
# shellcheck disable=SC1090
|
||
source "$CONFIG_FILE"
|
||
set +a
|
||
|
||
STATE_FILE="${OA_UPDATE_STATE_FILE:-${ERP_STATE_FILE:-$INSTALL_ROOT/state/update-state.json}}"
|
||
RUN_DIR="${ERP_RUN_DIR:-$INSTALL_ROOT/run}"
|
||
RELEASES_DIR="$INSTALL_ROOT/releases"
|
||
CURRENT_LINK="$INSTALL_ROOT/current"
|
||
HEALTH_URL="${ERP_HEALTH_URL:-http://127.0.0.1:${SERVER_PORT:-8091}/api/oa/health}"
|
||
GITEA_BASE_URL="${OA_UPDATE_GITEA_BASE_URL:-}"
|
||
REPOSITORY="${OA_UPDATE_REPOSITORY:-awaioi/ERP}"
|
||
CHANNEL="${OA_UPDATE_CHANNEL:-stable}"
|
||
TOKEN="${OA_UPDATE_TOKEN:-}"
|
||
ALLOW_INSECURE="${OA_UPDATE_ALLOW_INSECURE_HTTP:-false}"
|
||
if [[ "${GITEA_BASE_URL%/}" == "http://38.76.196.225:10099" ]]; then
|
||
GITEA_BASE_URL="https://git.awaioi.com"
|
||
ALLOW_INSECURE=false
|
||
fi
|
||
PUBLIC_KEY_FILE="${ERP_UPDATE_PUBLIC_KEY_FILE:-$INSTALL_ROOT/config/release-public-key.pem}"
|
||
REQUIRE_SIGNATURE="${ERP_UPDATE_REQUIRE_SIGNATURE:-true}"
|
||
BACKUP_MODE="${ERP_UPDATE_BACKUP_MODE:-none}"
|
||
HEALTH_TIMEOUT_SECONDS="${ERP_UPDATE_HEALTH_TIMEOUT_SECONDS:-120}"
|
||
HEALTH_POLL_SECONDS="${ERP_UPDATE_HEALTH_POLL_SECONDS:-2}"
|
||
TMP_DIR=""
|
||
LOCK_FILE="$RUN_DIR/update.lock"
|
||
FINAL_STATE=0
|
||
|
||
say() { printf '[ERP Update] %s\n' "$*"; }
|
||
|
||
write_state() {
|
||
local phase="$1" progress="$2" message="$3" version="${4:-}" error="${5:-}"
|
||
mkdir -p "$(dirname "$STATE_FILE")"
|
||
python3 - "$STATE_FILE" "$phase" "$progress" "$message" "$version" "$error" <<'PY'
|
||
import json, os, sys, tempfile
|
||
from datetime import datetime, timezone
|
||
|
||
path, phase, progress, message, version, error = sys.argv[1:]
|
||
payload = {}
|
||
try:
|
||
if os.path.getsize(path) <= 64 * 1024:
|
||
with open(path, encoding="utf-8") as handle:
|
||
existing = json.load(handle)
|
||
if isinstance(existing, dict):
|
||
payload = existing
|
||
except (OSError, json.JSONDecodeError, TypeError, ValueError):
|
||
pass
|
||
payload.update({
|
||
"phase": phase,
|
||
"progress": int(progress),
|
||
"message": message,
|
||
"version": version or None,
|
||
"error": error or None,
|
||
"updatedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||
})
|
||
parent = os.path.dirname(path) or "."
|
||
fd, tmp = tempfile.mkstemp(prefix=".update-state-", dir=parent, text=True)
|
||
try:
|
||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||
json.dump(payload, handle, ensure_ascii=False, separators=(",", ":"))
|
||
handle.write("\n")
|
||
os.chmod(tmp, 0o640)
|
||
os.replace(tmp, path)
|
||
finally:
|
||
if os.path.exists(tmp):
|
||
os.unlink(tmp)
|
||
PY
|
||
}
|
||
|
||
cleanup() {
|
||
if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then
|
||
rm -rf "$TMP_DIR"
|
||
fi
|
||
}
|
||
|
||
on_exit() {
|
||
local code=$?
|
||
if [[ "$code" != "0" && "$FINAL_STATE" != "1" ]]; then
|
||
write_state FAILED 0 "更新失败" "${TARGET_VERSION:-}" "更新助手异常退出(code $code)" || true
|
||
fi
|
||
cleanup
|
||
}
|
||
trap on_exit EXIT
|
||
|
||
fail() {
|
||
local message="$1"
|
||
write_state FAILED "${2:-0}" "更新失败" "${TARGET_VERSION:-}" "$message" || true
|
||
FINAL_STATE=1
|
||
printf '[ERP Update] ERROR: %s\n' "$message" >&2
|
||
exit 1
|
||
}
|
||
|
||
require_command() {
|
||
command -v "$1" >/dev/null 2>&1 || fail "缺少命令:$1"
|
||
}
|
||
|
||
validate_https_url() {
|
||
local value="$1"
|
||
case "$value" in
|
||
https://*) return 0 ;;
|
||
http://*) [[ "$ALLOW_INSECURE" == "true" || "$ALLOW_INSECURE" == "1" ]] && return 0 ;;
|
||
esac
|
||
fail "更新地址必须使用 HTTPS"
|
||
}
|
||
|
||
acquire_lock_or_reexec() {
|
||
[[ "${ERP_UPDATE_LOCK_HELD:-0}" == "1" ]] && return 0
|
||
mkdir -p "$RUN_DIR"
|
||
command -v python3 >/dev/null 2>&1 || {
|
||
printf '[ERP Update] ERROR: 缺少命令:python3\n' >&2
|
||
exit 1
|
||
}
|
||
exec python3 - "$LOCK_FILE" "$0" "$@" <<'PY'
|
||
import fcntl
|
||
import os
|
||
import shutil
|
||
import sys
|
||
import time
|
||
|
||
lock_path, script, *args = sys.argv[1:]
|
||
if os.path.isdir(lock_path):
|
||
owner = ""
|
||
try:
|
||
with open(os.path.join(lock_path, "pid"), encoding="ascii") as handle:
|
||
owner = handle.read().strip()
|
||
except OSError:
|
||
pass
|
||
alive = False
|
||
if owner.isdigit():
|
||
try:
|
||
os.kill(int(owner), 0)
|
||
alive = True
|
||
except OSError:
|
||
pass
|
||
try:
|
||
recent = time.time() - os.stat(lock_path).st_mtime < 30
|
||
except OSError:
|
||
recent = False
|
||
if alive or recent:
|
||
raise SystemExit(f"已有更新任务正在执行(PID {owner or 'unknown'})")
|
||
try:
|
||
shutil.rmtree(lock_path)
|
||
except FileNotFoundError:
|
||
pass
|
||
|
||
lock = open(lock_path, "a+", encoding="ascii")
|
||
try:
|
||
fcntl.flock(lock.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||
except BlockingIOError:
|
||
lock.seek(0)
|
||
owner = lock.read().strip() or "unknown"
|
||
raise SystemExit(f"已有更新任务正在执行(PID {owner})")
|
||
lock.seek(0)
|
||
lock.truncate()
|
||
lock.write(str(os.getpid()) + "\n")
|
||
lock.flush()
|
||
os.fchmod(lock.fileno(), 0o640)
|
||
os.set_inheritable(lock.fileno(), True)
|
||
env = os.environ.copy()
|
||
env["ERP_UPDATE_LOCK_HELD"] = "1"
|
||
script = os.path.abspath(script)
|
||
os.execve(script, [script, *args], env)
|
||
PY
|
||
}
|
||
|
||
create_curl_config() {
|
||
CURL_CONFIG="$TMP_DIR/curl.conf"
|
||
{
|
||
printf 'silent\nshow-error\nfail\nlocation\n'
|
||
printf 'connect-timeout = 15\nmax-time = 900\n'
|
||
if [[ "$ALLOW_INSECURE" == "true" || "$ALLOW_INSECURE" == "1" ]]; 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 "Gitea Token 格式无效"
|
||
printf 'header = "Authorization: token %s"\n' "$TOKEN"
|
||
fi
|
||
} > "$CURL_CONFIG"
|
||
chmod 600 "$CURL_CONFIG"
|
||
}
|
||
|
||
download() {
|
||
local url="$1" output="$2"
|
||
validate_https_url "$url"
|
||
curl --config "$CURL_CONFIG" --output "$output" "$url"
|
||
}
|
||
|
||
select_release() {
|
||
local release_json="$1" requested="$2" output="$3"
|
||
python3 - "$release_json" "$requested" "$CHANNEL" > "$output" <<'PY'
|
||
import json, re, sys
|
||
|
||
path, requested, channel = sys.argv[1:]
|
||
with open(path, encoding="utf-8") as handle:
|
||
release = json.load(handle)
|
||
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:
|
||
raise SystemExit("invalid release tag")
|
||
version = match.group(1)
|
||
if requested and requested.removeprefix("v") != version:
|
||
raise SystemExit("requested version is no longer latest")
|
||
if release.get("draft"):
|
||
raise SystemExit("latest release is a draft")
|
||
if channel.lower() == "stable" and release.get("prerelease"):
|
||
raise SystemExit("prerelease rejected on stable channel")
|
||
if channel.lower() == "stable" and "-" in version.split("+", 1)[0]:
|
||
raise SystemExit("prerelease tag rejected on stable channel")
|
||
assets = {str(item.get("name")): str(item.get("browser_download_url") or "") for item 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 value or any(ch.isspace() for ch in value) for value in urls):
|
||
raise SystemExit("release assets are incomplete")
|
||
print("\t".join([version, *urls]))
|
||
PY
|
||
}
|
||
|
||
verify_signature_and_checksum() {
|
||
local archive="$1" sums="$2" signature="$3" archive_name="$4"
|
||
write_state VERIFYING 45 "正在校验发布签名" "$TARGET_VERSION"
|
||
if [[ "$REQUIRE_SIGNATURE" == "true" || "$REQUIRE_SIGNATURE" == "1" ]]; then
|
||
[[ -r "$PUBLIC_KEY_FILE" ]] || fail "找不到发布公钥" 45
|
||
require_command openssl
|
||
local openssl_version algorithms
|
||
openssl_version="$(openssl version 2>/dev/null || true)"
|
||
[[ "$openssl_version" =~ ^OpenSSL[[:space:]]3\. ]] \
|
||
|| fail "签名验证需要 OpenSSL 3" 45
|
||
algorithms="$(openssl list -public-key-algorithms 2>/dev/null || true)"
|
||
grep -qi ED25519 <<< "$algorithms" \
|
||
|| fail "当前 OpenSSL 不支持 Ed25519" 45
|
||
openssl pkeyutl -verify -rawin -pubin -inkey "$PUBLIC_KEY_FILE" \
|
||
-sigfile "$signature" -in "$sums" >/dev/null \
|
||
|| fail "Release Ed25519 签名验证失败" 45
|
||
fi
|
||
|
||
local expected actual
|
||
expected="$(python3 - "$sums" "$archive_name" <<'PY'
|
||
import re, sys
|
||
path, wanted = sys.argv[1:]
|
||
for line in open(path, encoding="utf-8"):
|
||
match = re.fullmatch(r"([0-9a-fA-F]{64})\s+\*?(.+?)\s*", line)
|
||
if match and match.group(2) == wanted:
|
||
print(match.group(1).lower())
|
||
break
|
||
PY
|
||
)"
|
||
[[ "$expected" =~ ^[0-9a-f]{64}$ ]] || fail "SHA256SUMS 缺少安装包校验值" 50
|
||
if command -v sha256sum >/dev/null 2>&1; then
|
||
actual="$(sha256sum "$archive" | awk '{print $1}')"
|
||
else
|
||
actual="$(shasum -a 256 "$archive" | awk '{print $1}')"
|
||
fi
|
||
[[ "$actual" == "$expected" ]] || fail "安装包 SHA-256 校验失败" 50
|
||
}
|
||
|
||
extract_archive() {
|
||
local archive="$1" destination="$2"
|
||
python3 - "$archive" <<'PY'
|
||
import pathlib, sys, tarfile
|
||
with tarfile.open(sys.argv[1], "r:gz") as archive:
|
||
for member in archive.getmembers():
|
||
path = pathlib.PurePosixPath(member.name)
|
||
if path.is_absolute() or ".." in path.parts or member.issym() or member.islnk():
|
||
raise SystemExit(f"unsafe archive member: {member.name}")
|
||
PY
|
||
mkdir -p "$destination"
|
||
tar -xzf "$archive" -C "$destination"
|
||
}
|
||
|
||
atomic_switch() {
|
||
local target="$1"
|
||
python3 - "$CURRENT_LINK" "$target" <<'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
|
||
}
|
||
|
||
wait_for_restarted_health() {
|
||
local old_pid="$1" deadline=$((SECONDS + HEALTH_TIMEOUT_SECONDS))
|
||
while (( SECONDS < deadline )); do
|
||
local new_pid=""
|
||
[[ -r "$RUN_DIR/app.pid" ]] && new_pid="$(<"$RUN_DIR/app.pid")"
|
||
if [[ "$new_pid" =~ ^[0-9]+$ && "$new_pid" != "$old_pid" ]] \
|
||
&& kill -0 "$new_pid" 2>/dev/null \
|
||
&& curl -fsS --connect-timeout 2 --max-time 5 "$HEALTH_URL" >/dev/null 2>&1; then
|
||
return 0
|
||
fi
|
||
sleep "$HEALTH_POLL_SECONDS"
|
||
done
|
||
return 1
|
||
}
|
||
|
||
wait_for_pid_exit() {
|
||
local pid="$1" deadline=$((SECONDS + ${2:-60}))
|
||
while kill -0 "$pid" 2>/dev/null; do
|
||
(( SECONDS < deadline )) || return 1
|
||
sleep 1
|
||
done
|
||
}
|
||
|
||
clear_pid_file() {
|
||
local expected="$1" current=""
|
||
[[ -r "$RUN_DIR/app.pid" ]] && current="$(<"$RUN_DIR/app.pid")"
|
||
if [[ "$current" == "$expected" ]] && ! kill -0 "$expected" 2>/dev/null; then
|
||
rm -f "$RUN_DIR/app.pid"
|
||
fi
|
||
}
|
||
|
||
terminate_application_pid() {
|
||
local pid="$1"
|
||
[[ "$pid" =~ ^[0-9]+$ && "$pid" != "$$" ]] || return 0
|
||
if kill -0 "$pid" 2>/dev/null; then
|
||
kill -TERM "$pid" 2>/dev/null || return 1
|
||
wait_for_pid_exit "$pid" 60 || return 1
|
||
fi
|
||
clear_pid_file "$pid"
|
||
}
|
||
|
||
stop_application() {
|
||
local pid=""
|
||
[[ -r "$RUN_DIR/app.pid" ]] && pid="$(<"$RUN_DIR/app.pid")"
|
||
[[ "$pid" =~ ^[0-9]+$ ]] || pid="${ERP_APP_PID:-}"
|
||
[[ "$pid" =~ ^[0-9]+$ ]] || fail "无法读取 ERP 服务 PID" 75
|
||
[[ "$pid" != "$$" ]] || fail "拒绝停止更新助手自身" 75
|
||
terminate_application_pid "$pid" || fail "ERP 服务 PID $pid 未能在 60 秒内停止" 75
|
||
printf '%s\n' "$pid"
|
||
}
|
||
|
||
backup_postgres_if_enabled() {
|
||
[[ "$BACKUP_MODE" == "pg_dump" ]] || return 0
|
||
require_command pg_dump
|
||
local backup_dir="$INSTALL_ROOT/backups"
|
||
mkdir -p "$backup_dir"
|
||
local output="$backup_dir/pre-${TARGET_VERSION}-$(date -u +%Y%m%dT%H%M%SZ).dump"
|
||
write_state INSTALLING 60 "正在创建 PostgreSQL 逻辑备份" "$TARGET_VERSION"
|
||
PGPASSWORD="${OA_DB_PASSWORD:-}" PGSSLMODE="${ERP_PGSSLMODE:-prefer}" pg_dump \
|
||
--host="${ERP_PGHOST:-127.0.0.1}" \
|
||
--port="${ERP_PGPORT:-5432}" \
|
||
--username="${OA_DB_USERNAME:-}" \
|
||
--dbname="${ERP_PGDATABASE:-oa}" \
|
||
--format=custom --file="$output" \
|
||
|| fail "PostgreSQL 备份失败,已取消更新" 60
|
||
chmod 600 "$output"
|
||
}
|
||
|
||
install_release() {
|
||
local requested="${1:-}"
|
||
validate_https_url "$GITEA_BASE_URL"
|
||
[[ "$HEALTH_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]] \
|
||
|| fail "健康检查超时时间配置无效"
|
||
[[ "$HEALTH_POLL_SECONDS" =~ ^[1-9][0-9]*$ ]] \
|
||
|| fail "健康检查间隔配置无效"
|
||
[[ "$REPOSITORY" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || fail "更新仓库配置无效"
|
||
require_command curl
|
||
require_command python3
|
||
require_command tar
|
||
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kaidi-erp-update.XXXXXX")"
|
||
create_curl_config
|
||
|
||
write_state CHECKING 5 "正在读取 Gitea Release" "$requested"
|
||
local owner="${REPOSITORY%%/*}" repo="${REPOSITORY#*/}"
|
||
local api_url="${GITEA_BASE_URL%/}/api/v1/repos/$owner/$repo/releases/latest"
|
||
local release_json="$TMP_DIR/release.json"
|
||
download "$api_url" "$release_json"
|
||
|
||
local selection="$TMP_DIR/selection"
|
||
select_release "$release_json" "$requested" "$selection" || fail "Release 元数据验证失败" 10
|
||
local archive_url sums_url signature_url
|
||
IFS=$'\t' read -r TARGET_VERSION archive_url sums_url signature_url < "$selection"
|
||
local archive_name="kaidi-erp-${TARGET_VERSION}.tar.gz"
|
||
local archive="$TMP_DIR/$archive_name" sums="$TMP_DIR/SHA256SUMS" signature="$TMP_DIR/SHA256SUMS.sig"
|
||
|
||
write_state DOWNLOADING 20 "正在下载版本 ${TARGET_VERSION}" "$TARGET_VERSION"
|
||
download "$archive_url" "$archive"
|
||
download "$sums_url" "$sums"
|
||
download "$signature_url" "$signature"
|
||
verify_signature_and_checksum "$archive" "$sums" "$signature" "$archive_name"
|
||
|
||
write_state INSTALLING 55 "正在准备版本 ${TARGET_VERSION}" "$TARGET_VERSION"
|
||
local unpack="$TMP_DIR/unpack"
|
||
extract_archive "$archive" "$unpack" || fail "安装包结构验证失败" 55
|
||
local source="$unpack/kaidi-erp-${TARGET_VERSION}"
|
||
[[ -r "$source/app/kaidi-erp.jar" && -x "$source/bin/erp-run" && -x "$source/bin/erp-update" ]] \
|
||
|| fail "安装包缺少运行文件" 55
|
||
[[ "$(<"$source/VERSION")" == "$TARGET_VERSION" ]] || fail "安装包版本与 Release 不一致" 55
|
||
python3 - "$source/manifest.json" "$TARGET_VERSION" <<'PY' || fail "版本清单不允许自动回滚" 55
|
||
import json, sys
|
||
manifest = json.load(open(sys.argv[1], encoding="utf-8"))
|
||
if manifest.get("version") != sys.argv[2] or manifest.get("database") != "postgresql":
|
||
raise SystemExit(1)
|
||
if manifest.get("rollbackCompatible") is not True:
|
||
raise SystemExit(1)
|
||
PY
|
||
|
||
mkdir -p "$RELEASES_DIR"
|
||
local release_dir="$RELEASES_DIR/$TARGET_VERSION"
|
||
if [[ -r "$CURRENT_LINK/VERSION" && "$(<"$CURRENT_LINK/VERSION")" == "$TARGET_VERSION" ]]; then
|
||
fail "目标版本已经安装" 55
|
||
fi
|
||
local replaced=""
|
||
if [[ -e "$release_dir" ]]; then
|
||
replaced="${release_dir}.replaced-$$"
|
||
mv "$release_dir" "$replaced"
|
||
fi
|
||
if ! mv "$source" "$release_dir"; then
|
||
[[ -z "$replaced" || ! -e "$replaced" ]] || mv "$replaced" "$release_dir"
|
||
fail "无法写入目标版本目录" 55
|
||
fi
|
||
[[ -z "$replaced" ]] || rm -rf "$replaced"
|
||
backup_postgres_if_enabled
|
||
|
||
local previous=""
|
||
[[ -L "$CURRENT_LINK" ]] && previous="$(readlink "$CURRENT_LINK")"
|
||
[[ -n "$previous" ]] || fail "当前版本链接不存在,拒绝无回滚点更新" 70
|
||
atomic_switch "releases/$TARGET_VERSION"
|
||
|
||
write_state RESTARTING 80 "正在重启 ERP 服务" "$TARGET_VERSION"
|
||
local old_pid
|
||
old_pid="$(stop_application)"
|
||
if wait_for_restarted_health "$old_pid"; then
|
||
write_state SUCCEEDED 100 "更新完成" "$TARGET_VERSION"
|
||
FINAL_STATE=1
|
||
say "updated to $TARGET_VERSION"
|
||
return 0
|
||
fi
|
||
|
||
write_state ROLLING_BACK 90 "新版本健康检查失败,正在回滚" "$TARGET_VERSION"
|
||
atomic_switch "$previous"
|
||
local failed_pid=""
|
||
[[ -r "$RUN_DIR/app.pid" ]] && failed_pid="$(<"$RUN_DIR/app.pid")"
|
||
terminate_application_pid "$failed_pid" || true
|
||
if wait_for_restarted_health "$failed_pid"; then
|
||
write_state ROLLED_BACK 100 "新版本不可用,已恢复上一版本" "$TARGET_VERSION" "健康检查失败"
|
||
FINAL_STATE=1
|
||
exit 1
|
||
fi
|
||
fail "新版本与回滚版本均未通过健康检查,需要人工处理" 100
|
||
}
|
||
|
||
main() {
|
||
local command="${1:-}" version="${2:-}"
|
||
case "$command" in
|
||
install)
|
||
acquire_lock_or_reexec "$@"
|
||
install_release "$version"
|
||
;;
|
||
*) printf 'usage: erp-update install <version>\n' >&2; exit 2 ;;
|
||
esac
|
||
}
|
||
|
||
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
|
||
main "$@"
|
||
fi
|