feat: add jetboard deployment manager

This commit is contained in:
JetSprow
2026-05-01 01:50:37 +10:00
parent 4dd2f9280f
commit 6d6489817d
9 changed files with 1854 additions and 18 deletions

View File

@@ -0,0 +1,603 @@
#!/usr/bin/env bash
set -euo pipefail
GH_REPO="${GH_REPO:-JetSprow/J-Board-Lite}"
BRANCH="${BRANCH:-main}"
APP_DIR="${APP_DIR:-}"
REWRITE_ENV="${REWRITE_ENV:-}"
SKIP_NODE_INSTALL="${SKIP_NODE_INSTALL:-0}"
NODE_MAJOR="${NODE_MAJOR:-20}"
SERVICE_NAME="${JBOARD_SERVICE_NAME:-jetboard}"
SERVICE_USER="${JBOARD_SERVICE_USER:-jetboard}"
CONFIG_FILE="${JETBOARD_CONFIG:-/etc/jetboard.conf}"
APP_PORT=""
PUBLIC_URL=""
SUBSCRIPTION_PUBLIC_URL=""
SITE_NAME=""
ADMIN_EMAIL=""
ADMIN_PASSWORD=""
ADMIN_NAME=""
NEXTAUTH_SECRET=""
ENCRYPTION_KEY=""
ENV_REUSED="0"
RUNTIME_NODE_OPTIONS=""
is_interactive() {
[ -r /dev/tty ] && [ -w /dev/tty ]
}
prompt_print() {
if is_interactive; then
printf '%b' "$*" > /dev/tty
else
printf '%b' "$*" >&2
fi
}
prompt_read() {
local __var="$1"
if is_interactive; then
IFS= read -r "$__var" < /dev/tty || true
else
return 1
fi
}
run_as_root() {
if [ "$(id -u)" -eq 0 ]; then
"$@"
elif command -v sudo >/dev/null 2>&1; then
sudo "$@"
else
echo "需要 root 权限。请使用 root 用户运行,或先安装 sudo。" >&2
exit 1
fi
}
line() {
printf '%s\n' "------------------------------------------------------------"
}
section() {
echo
line
printf '%s\n' "$1"
line
}
need_command() {
command -v "$1" >/dev/null 2>&1
}
install_base_packages() {
section "安装基础依赖"
if need_command apt-get; then
run_as_root apt-get update
run_as_root apt-get install -y ca-certificates curl git openssl python3 make g++ build-essential
elif need_command dnf; then
run_as_root dnf install -y ca-certificates curl git openssl python3 make gcc gcc-c++
elif need_command yum; then
run_as_root yum install -y ca-certificates curl git openssl python3 make gcc gcc-c++
elif need_command apk; then
run_as_root apk add --no-cache ca-certificates curl git openssl python3 make g++
else
echo "无法识别包管理器请先手动安装curl git openssl nodejs npm python3 make g++" >&2
exit 1
fi
}
node_major_version() {
if ! need_command node; then
echo 0
return
fi
local version
version="$(node -v 2>/dev/null | sed 's/^v//; s/\..*$//' || true)"
case "$version" in
''|*[!0-9]*) echo 0 ;;
*) echo "$version" ;;
esac
}
install_node() {
if [ "$SKIP_NODE_INSTALL" = "1" ]; then
return
fi
local current_major
current_major="$(node_major_version)"
if [ "$current_major" -ge "$NODE_MAJOR" ] && need_command npm; then
echo "Node.js $(node -v) 已满足要求。"
return
fi
section "安装 Node.js ${NODE_MAJOR}"
local setup_tmp
setup_tmp="$(mktemp)"
if need_command apt-get; then
curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" -o "$setup_tmp"
run_as_root bash "$setup_tmp"
run_as_root apt-get install -y nodejs
elif need_command dnf; then
curl -fsSL "https://rpm.nodesource.com/setup_${NODE_MAJOR}.x" -o "$setup_tmp"
run_as_root bash "$setup_tmp"
run_as_root dnf install -y nodejs
elif need_command yum; then
curl -fsSL "https://rpm.nodesource.com/setup_${NODE_MAJOR}.x" -o "$setup_tmp"
run_as_root bash "$setup_tmp"
run_as_root yum install -y nodejs
elif need_command apk; then
run_as_root apk add --no-cache nodejs npm
else
echo "无法自动安装 Node.js请先手动安装 Node.js ${NODE_MAJOR}+ 和 npm。" >&2
exit 1
fi
rm -f "$setup_tmp"
current_major="$(node_major_version)"
if [ "$current_major" -lt "$NODE_MAJOR" ] || ! need_command npm; then
echo "Node.js 安装后仍不满足要求,请检查 node/npm。" >&2
exit 1
fi
}
random_hex() {
openssl rand -hex "$1"
}
random_password() {
openssl rand -hex 12
}
server_ip() {
curl -fsS --max-time 3 https://api.ipify.org 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}' || echo "127.0.0.1"
}
normalize_url() {
local value="$1"
value="${value%/}"
if [ -z "$value" ]; then
printf '%s' "$value"
return
fi
case "$value" in
http://*|https://*) printf '%s' "$value" ;;
*) printf 'https://%s' "$value" ;;
esac
}
prompt_value() {
local label="$1"
local default="$2"
local help="${3:-}"
local value=""
if [ -n "$help" ]; then
prompt_print "$help\n"
fi
if is_interactive; then
prompt_print "$label [$default]: "
prompt_read value || true
fi
if [ -z "$value" ]; then
value="$default"
fi
printf '%s' "$value"
}
prompt_generated() {
local label="$1"
local default="$2"
local help="${3:-}"
local value=""
if [ -n "$help" ]; then
prompt_print "$help\n"
fi
if is_interactive; then
prompt_print "$label [回车自动生成]: "
prompt_read value || true
fi
if [ -z "$value" ]; then
value="$default"
fi
printf '%s' "$value"
}
prompt_yes_no() {
local label="$1"
local default="$2"
local value=""
if ! is_interactive; then
printf '%s' "$default"
return
fi
prompt_print "$label [$default]: "
prompt_read value || true
value="${value:-$default}"
case "$value" in
y|Y|yes|YES|Yes) printf 'y' ;;
*) printf 'n' ;;
esac
}
resolve_default_app_dir() {
local source="${BASH_SOURCE[0]:-}"
local dir=""
if [ -n "$source" ] && [ -f "$source" ]; then
dir="$(cd -- "$(dirname -- "$source")" && pwd)"
if [ -f "$dir/../package.json" ]; then
cd -- "$dir/.." && pwd
return
fi
fi
echo "/opt/jboard"
}
git_in_repo() {
if [ -w "$APP_DIR/.git" ] || [ -w "$APP_DIR" ]; then
git -c safe.directory="$APP_DIR" "$@"
else
run_as_root git -c safe.directory="$APP_DIR" "$@"
fi
}
run_in_app_dir() {
if [ -w "$APP_DIR" ] && { [ ! -f "$APP_DIR/.env" ] || [ -r "$APP_DIR/.env" ]; } && [ "$(id -u)" -ne 0 ]; then
"$@"
else
run_as_root "$@"
fi
}
load_resource_helpers() {
local helper="$APP_DIR/scripts/lib-standalone-profile.sh"
if [ -f "$helper" ]; then
# shellcheck disable=SC1090
. "$helper"
else
echo "未找到本机资源检测脚本:$helper,将使用默认构建策略。"
fi
}
prepare_repo() {
section "准备 J-Board Lite 代码"
local default_dir
default_dir="$(resolve_default_app_dir)"
APP_DIR="$(prompt_value "安装目录" "${APP_DIR:-$default_dir}" "如果你已经在仓库目录里运行脚本,直接回车即可。")"
if [ -d "$APP_DIR/.git" ]; then
echo "检测到已有仓库:$APP_DIR"
cd "$APP_DIR"
git_in_repo fetch origin "$BRANCH"
git_in_repo checkout "$BRANCH"
git_in_repo pull --ff-only origin "$BRANCH"
elif [ -e "$APP_DIR" ] && [ "$(find "$APP_DIR" -mindepth 1 -maxdepth 1 2>/dev/null | wc -l | tr -d ' ')" != "0" ]; then
echo "安装目录已存在且不为空:$APP_DIR" >&2
echo "请换一个目录,或设置 APP_DIR 指向空目录/已有 J-Board Lite 仓库。" >&2
exit 1
else
run_as_root mkdir -p "$(dirname "$APP_DIR")"
run_as_root git clone --branch "$BRANCH" "https://github.com/${GH_REPO}.git" "$APP_DIR"
cd "$APP_DIR"
fi
}
load_existing_env() {
if [ -f .env ]; then
if [ -r .env ]; then
set -a
# shellcheck disable=SC1091
. ./.env
set +a
else
echo ".env 存在但当前用户不可读取;如需显示管理员密码,请使用 root 重新运行或查看 .env。"
fi
APP_PORT="${APP_PORT:-3000}"
PUBLIC_URL="${NEXTAUTH_URL:-}"
SUBSCRIPTION_PUBLIC_URL="${SUBSCRIPTION_URL:-}"
SITE_NAME="${SITE_NAME:-J-Board Lite}"
ADMIN_EMAIL="${ADMIN_EMAIL:-admin@jboard.local}"
ADMIN_PASSWORD="${ADMIN_PASSWORD:-}"
ADMIN_NAME="${ADMIN_NAME:-Admin}"
fi
}
env_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\$/\\$/g'
}
write_env() {
local tmp
tmp="$(mktemp)"
{
printf '# J-Board Lite standalone panel\n'
printf 'APP_PORT="%s"\n' "$(env_escape "$APP_PORT")"
printf 'SITE_NAME="%s"\n' "$(env_escape "$SITE_NAME")"
printf '\n# SQLite for standalone runtime\n'
printf 'DATABASE_URL="file:%s/storage/jboard.db"\n' "$(env_escape "$APP_DIR")"
printf '\n# NextAuth\n'
printf 'NEXTAUTH_SECRET="%s"\n' "$(env_escape "$NEXTAUTH_SECRET")"
printf 'NEXTAUTH_URL="%s"\n' "$(env_escape "$PUBLIC_URL")"
printf 'SUBSCRIPTION_URL="%s"\n' "$(env_escape "$SUBSCRIPTION_PUBLIC_URL")"
printf '\n# Must be at least 32 bytes, used for AES-256-GCM encryption\n'
printf 'ENCRYPTION_KEY="%s"\n' "$(env_escape "$ENCRYPTION_KEY")"
printf '\n# Initial admin account, used by npm run db:seed on first install\n'
printf 'ADMIN_EMAIL="%s"\n' "$(env_escape "$ADMIN_EMAIL")"
printf 'ADMIN_PASSWORD="%s"\n' "$(env_escape "$ADMIN_PASSWORD")"
printf 'ADMIN_NAME="%s"\n' "$(env_escape "$ADMIN_NAME")"
} > "$tmp"
if [ -f .env ]; then
run_as_root cp .env ".env.backup.$(date +%Y%m%d%H%M%S)"
fi
run_as_root install -m 0600 "$tmp" .env
rm -f "$tmp"
}
configure_env() {
section "生成 .env 配置"
if [ -f .env ] && [ -z "$REWRITE_ENV" ]; then
local answer
answer="$(prompt_yes_no "检测到已有 .env是否重新生成" "n")"
if [ "$answer" = "n" ]; then
ENV_REUSED="1"
load_existing_env
echo "沿用现有 .env。"
return
fi
elif [ "${REWRITE_ENV:-0}" = "0" ] && [ -f .env ]; then
ENV_REUSED="1"
load_existing_env
echo "沿用现有 .env。"
return
fi
local ip default_url
ip="$(server_ip)"
default_url="http://${ip}:3000"
SITE_NAME="$(prompt_value "站点名称" "J-Board Lite")"
PUBLIC_URL="$(prompt_value "网站访问地址" "$default_url" "这里请填写你准备反向代理到本机 3000 端口的面板域名,例如 https://panel.example.com。没有域名时可先回车用 IP:3000 测试。")"
PUBLIC_URL="$(normalize_url "$PUBLIC_URL")"
SUBSCRIPTION_PUBLIC_URL="$(prompt_value "订阅访问地址" "$PUBLIC_URL" "用于生成客户端订阅链接。可以和网站地址相同,也可以填单独反代到本面板的订阅域名,例如 https://sub.example.com。")"
SUBSCRIPTION_PUBLIC_URL="$(normalize_url "$SUBSCRIPTION_PUBLIC_URL")"
APP_PORT="$(prompt_value "本机监听端口" "3000" "反向代理目标会是 http://127.0.0.1:端口,默认 3000。")"
ADMIN_EMAIL="$(prompt_value "管理员邮箱" "admin@jboard.local")"
ADMIN_PASSWORD="$(prompt_generated "管理员密码" "$(random_password)" "回车会生成一个安全密码,部署完成后会在结果中显示一次。")"
ADMIN_NAME="$(prompt_value "管理员昵称" "Admin")"
NEXTAUTH_SECRET="$(prompt_generated "NEXTAUTH_SECRET" "$(random_hex 32)")"
ENCRYPTION_KEY="$(prompt_generated "ENCRYPTION_KEY" "$(random_hex 32)" "生产使用后不要更换 ENCRYPTION_KEY否则已加密的面板密码、Token、凭据会无法解密。")"
write_env
echo ".env 已写入:$APP_DIR/.env"
}
ensure_service_user() {
if id "$SERVICE_USER" >/dev/null 2>&1; then
return
fi
section "创建运行用户:$SERVICE_USER"
if need_command useradd; then
run_as_root useradd --system --user-group --home "$APP_DIR" --shell /usr/sbin/nologin "$SERVICE_USER"
elif need_command adduser; then
run_as_root adduser -S -D -H -h "$APP_DIR" -s /sbin/nologin "$SERVICE_USER"
else
echo "无法创建系统用户,请手动创建 $SERVICE_USER" >&2
exit 1
fi
}
prepare_runtime_tree() {
[ -f "$APP_DIR/.next/standalone/server.js" ] || {
echo "未找到 standalone 构建产物:$APP_DIR/.next/standalone/server.js" >&2
exit 1
}
run_in_app_dir mkdir -p "$APP_DIR/.next/standalone/.next"
run_in_app_dir rm -rf "$APP_DIR/.next/standalone/.next/static"
run_in_app_dir cp -R "$APP_DIR/.next/static" "$APP_DIR/.next/standalone/.next/"
if [ -d "$APP_DIR/public" ]; then
run_in_app_dir rm -rf "$APP_DIR/.next/standalone/public"
run_in_app_dir cp -R "$APP_DIR/public" "$APP_DIR/.next/standalone/public"
fi
if [ -d "$APP_DIR/data" ]; then
run_in_app_dir rm -rf "$APP_DIR/.next/standalone/data"
run_in_app_dir cp -R "$APP_DIR/data" "$APP_DIR/.next/standalone/data"
fi
}
install_dependencies_and_build() {
section "安装依赖并构建"
if command -v jboard_prepare_standalone_build_env >/dev/null 2>&1; then
jboard_prepare_standalone_build_env "$APP_DIR"
jboard_print_standalone_profile
RUNTIME_NODE_OPTIONS="${JBOARD_RUNTIME_NODE_OPTIONS:-}"
else
export NEXT_TELEMETRY_DISABLED=1
fi
run_as_root mkdir -p "$APP_DIR/storage" "$APP_DIR/backups"
run_in_app_dir env \
NEXT_TELEMETRY_DISABLED="${NEXT_TELEMETRY_DISABLED:-1}" \
NODE_OPTIONS="${NODE_OPTIONS:-}" \
NPM_CONFIG_JOBS="${NPM_CONFIG_JOBS:-}" \
npm ci --no-audit --no-fund
run_in_app_dir env \
NEXT_TELEMETRY_DISABLED="${NEXT_TELEMETRY_DISABLED:-1}" \
NODE_OPTIONS="${NODE_OPTIONS:-}" \
NPM_CONFIG_JOBS="${NPM_CONFIG_JOBS:-}" \
npm run build
prepare_runtime_tree
run_in_app_dir npm run db:push
run_in_app_dir npm run db:seed
run_as_root chown -R "$SERVICE_USER" "$APP_DIR/storage" "$APP_DIR/backups"
}
write_service() {
section "安装 systemd 服务"
if ! need_command systemctl; then
echo "当前系统没有 systemd无法自动安装服务。请使用支持 systemd 的 Linux 发行版。" >&2
exit 1
fi
local tmp
tmp="$(mktemp)"
{
printf '[Unit]\n'
printf 'Description=J-Board Lite standalone panel\n'
printf 'After=network-online.target\n'
printf 'Wants=network-online.target\n'
printf '\n[Service]\n'
printf 'Type=simple\n'
printf 'User=%s\n' "$SERVICE_USER"
printf 'WorkingDirectory=%s/.next/standalone\n' "$APP_DIR"
printf 'EnvironmentFile=%s/.env\n' "$APP_DIR"
printf 'Environment=NODE_ENV=production\n'
printf 'Environment=PORT=%s\n' "${APP_PORT:-3000}"
printf 'Environment=HOSTNAME=0.0.0.0\n'
if [ -n "$RUNTIME_NODE_OPTIONS" ]; then
printf 'Environment=NODE_OPTIONS=%s\n' "$RUNTIME_NODE_OPTIONS"
fi
printf 'ExecStart=/usr/bin/env node server.js\n'
printf 'Restart=always\n'
printf 'RestartSec=3\n'
printf 'NoNewPrivileges=true\n'
printf 'PrivateTmp=true\n'
printf '\n[Install]\n'
printf 'WantedBy=multi-user.target\n'
} > "$tmp"
run_as_root install -m 0644 "$tmp" "/etc/systemd/system/${SERVICE_NAME}.service"
rm -f "$tmp"
run_as_root systemctl daemon-reload
run_as_root systemctl enable --now "$SERVICE_NAME"
}
install_jetboard_command() {
section "安装 JetBoard 管理命令"
local config_tmp wrapper_tmp
config_tmp="$(mktemp)"
wrapper_tmp="$(mktemp)"
{
printf '# JetBoard standalone command configuration\n'
printf 'JBOARD_DEPLOY_MODE="standalone"\n'
printf 'JBOARD_APP_DIR="%s"\n' "$(env_escape "$APP_DIR")"
printf 'JBOARD_SERVICE_NAME="%s"\n' "$(env_escape "$SERVICE_NAME")"
printf 'JBOARD_SERVICE_USER="%s"\n' "$(env_escape "$SERVICE_USER")"
printf 'GH_REPO="%s"\n' "$(env_escape "$GH_REPO")"
printf 'BRANCH="%s"\n' "$(env_escape "$BRANCH")"
} > "$config_tmp"
{
printf '#!/usr/bin/env bash\n'
printf 'set -euo pipefail\n'
printf 'CONFIG_FILE="${JETBOARD_CONFIG:-/etc/jetboard.conf}"\n'
printf 'if [ -r "$CONFIG_FILE" ]; then\n'
printf ' # shellcheck disable=SC1090\n'
printf ' . "$CONFIG_FILE"\n'
printf 'fi\n'
printf 'APP_DIR="${JBOARD_APP_DIR:-/opt/jboard}"\n'
printf 'exec "$APP_DIR/scripts/jetboard.sh" "$@"\n'
} > "$wrapper_tmp"
run_as_root mkdir -p /usr/local/bin
run_as_root install -m 0644 "$config_tmp" "$CONFIG_FILE"
run_as_root install -m 0755 "$wrapper_tmp" /usr/local/bin/jetboard
run_as_root ln -sf /usr/local/bin/jetboard /usr/local/bin/JetBoard || true
run_as_root chmod +x "$APP_DIR/scripts/jetboard.sh" || true
rm -f "$config_tmp" "$wrapper_tmp"
echo "已安装jetboard也可使用 JetBoard"
}
wait_for_app() {
section "检查启动状态"
local url="http://127.0.0.1:${APP_PORT:-3000}/api/public/app-info"
local ok="0"
for _ in $(seq 1 30); do
if curl -fsS "$url" >/dev/null 2>&1; then
ok="1"
break
fi
sleep 2
done
run_as_root systemctl --no-pager status "$SERVICE_NAME" || true
echo
if [ "$ok" = "1" ]; then
echo "健康检查通过:$url"
else
echo "健康检查暂未通过请查看日志jetboard logs"
fi
}
print_summary() {
local proxy_target="http://127.0.0.1:${APP_PORT:-3000}"
local shown_password="$ADMIN_PASSWORD"
if [ "$ENV_REUSED" = "1" ] && [ -z "$shown_password" ]; then
shown_password="沿用已有数据库账号;如忘记可执行 jetboard reset"
fi
echo
printf '%s\n' "============================================================"
printf '%s\n' "J-Board Lite standalone 部署完成"
printf '%s\n' "============================================================"
printf '访问地址:%s\n' "${PUBLIC_URL:-http://127.0.0.1:${APP_PORT:-3000}}"
printf '订阅地址:%s\n' "${SUBSCRIPTION_PUBLIC_URL:-${PUBLIC_URL:-http://127.0.0.1:${APP_PORT:-3000}}}"
printf '反代目标:%s\n' "$proxy_target"
printf '管理员邮箱:%s\n' "${ADMIN_EMAIL:-admin@jboard.local}"
printf '管理员密码:%s\n' "$shown_password"
echo
printf '%s\n' "常用命令"
printf ' jetboard # 打开 JetBoard 管理菜单\n'
printf ' jetboard status # 查看服务状态\n'
printf ' jetboard update # 拉取代码、备份数据库并本机构建\n'
printf ' jetboard reset # 重置或创建管理员账号密码\n'
printf ' jetboard logs # 查看服务日志\n'
printf ' jetboard uninstall # 完整卸载 standalone 部署\n'
printf '%s\n' "============================================================"
}
main() {
section "J-Board Lite standalone 一键部署向导"
echo "这个脚本不会安装或使用 Docker。"
echo "它会安装 Node.js、拉取代码、生成 .env、构建 standalone 产物、初始化 SQLite 并注册 systemd 服务。"
install_base_packages
install_node
prepare_repo
load_resource_helpers
configure_env
ensure_service_user
install_dependencies_and_build
write_service
install_jetboard_command
wait_for_app
print_summary
}
main "$@"