#!/usr/bin/env bash set -euo pipefail BRAND="JetBoard" CONFIG_FILE="${JETBOARD_CONFIG:-/etc/jetboard.conf}" DEPLOY_MODE="${JBOARD_DEPLOY_MODE:-auto}" SERVICE_NAME="${JBOARD_SERVICE_NAME:-jetboard}" SERVICE_USER="${JBOARD_SERVICE_USER:-jetboard}" APP_DIR="${JBOARD_APP_DIR:-${APP_DIR:-}}" COMPOSE="${COMPOSE:-docker compose}" line() { printf '%s\n' "------------------------------------------------------------" } section() { echo line printf '%s\n' "$1" line } fail() { echo "$1" >&2 exit 1 } 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 fail "需要 root 权限。请使用 root 用户运行,或先安装 sudo。" fi } load_config() { if [ -r "$CONFIG_FILE" ]; then # shellcheck disable=SC1090 . "$CONFIG_FILE" DEPLOY_MODE="${JBOARD_DEPLOY_MODE:-${DEPLOY_MODE:-auto}}" SERVICE_NAME="${JBOARD_SERVICE_NAME:-${SERVICE_NAME:-jetboard}}" SERVICE_USER="${JBOARD_SERVICE_USER:-${SERVICE_USER:-jetboard}}" APP_DIR="${JBOARD_APP_DIR:-${APP_DIR:-}}" COMPOSE="${COMPOSE:-docker compose}" fi } resolve_app_dir() { if [ -n "$APP_DIR" ]; then printf '%s' "$APP_DIR" return fi 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 printf '%s' "/opt/jboard" } detect_deploy_mode() { case "$DEPLOY_MODE" in docker|standalone) return ;; auto|'') ;; *) echo "未知 JBOARD_DEPLOY_MODE=$DEPLOY_MODE,回退为 auto。" >&2 ;; esac if [ -f "/etc/systemd/system/${SERVICE_NAME}.service" ] && [ -f "$APP_DIR/.next/standalone/server.js" ]; then DEPLOY_MODE="standalone" elif [ -f "$APP_DIR/docker-compose.yml" ]; then DEPLOY_MODE="docker" else DEPLOY_MODE="standalone" fi } load_config APP_DIR="$(resolve_app_dir)" [ -d "$APP_DIR" ] || fail "未找到 J-Board Lite 安装目录:$APP_DIR" [ -f "$APP_DIR/package.json" ] || fail "安装目录不是有效的 J-Board Lite 仓库:$APP_DIR" cd "$APP_DIR" detect_deploy_mode load_env_for_prompts() { if [ -r .env ]; then set -a # shellcheck disable=SC1091 . ./.env set +a elif [ "$(id -u)" -ne 0 ] && command -v sudo >/dev/null 2>&1; then local env_dump env_dump="$(sudo sh -c "set -a; . '$APP_DIR/.env' 2>/dev/null; env" || true)" if [ -n "$env_dump" ]; then while IFS='=' read -r key value; do case "$key" in APP_PORT|SITE_NAME|NEXTAUTH_URL|SUBSCRIPTION_URL|ADMIN_EMAIL|ADMIN_NAME) export "$key=$value" ;; esac done </dev/null 2>&1; then openssl rand -hex 12 else date +%s | cksum | awk '{print "jb" $1 $2}' fi } prompt_value() { local label="$1" local default="$2" local value="" if is_interactive; then prompt_print "$label [$default]: " prompt_read value || true fi if [ -z "$value" ]; then value="$default" fi printf '%s' "$value" } prompt_secret_or_generated() { local label="$1" local default="$2" local value="" if is_interactive; then prompt_print "$label [回车自动生成]: " prompt_read value || true fi if [ -z "$value" ]; then value="$default" fi printf '%s' "$value" } 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 } docker_compose() { local -a compose_cmd local -a env_args=() read -r -a compose_cmd <<< "$COMPOSE" if [ "${#compose_cmd[@]}" -eq 0 ]; then compose_cmd=(docker compose) fi if [ -n "${COMPOSE_PARALLEL_LIMIT:-}" ]; then env_args+=("COMPOSE_PARALLEL_LIMIT=$COMPOSE_PARALLEL_LIMIT") fi if [ "$(id -u)" -eq 0 ]; then env "${env_args[@]}" "${compose_cmd[@]}" "$@" else run_as_root env "${env_args[@]}" "${compose_cmd[@]}" "$@" fi } standalone_service_ctl() { run_as_root systemctl "$@" "$SERVICE_NAME" } standalone_logs() { run_as_root journalctl -u "$SERVICE_NAME" -f } standalone_backup_sqlite_database() { run_as_root mkdir -p "$APP_DIR/backups" local backup_path="$APP_DIR/backups/jboard-standalone-$(date +%F-%H%M%S).tar.gz" if [ -f "$APP_DIR/storage/jboard.db" ]; then run_as_root tar -C "$APP_DIR/storage" -czf "$backup_path" \ jboard.db \ $( [ -f "$APP_DIR/storage/jboard.db-wal" ] && printf '%s' "jboard.db-wal" || true ) \ $( [ -f "$APP_DIR/storage/jboard.db-shm" ] && printf '%s' "jboard.db-shm" || true ) echo "SQLite backup saved: $backup_path" else echo "No existing SQLite database found; skipping database backup." fi } prepare_runtime_tree() { [ -f "$APP_DIR/.next/standalone/server.js" ] || fail "未找到 standalone 构建产物,请先执行 jetboard update 或重新安装。" 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 } standalone_build_app() { section "$BRAND 构建" load_standalone_resource_helpers if command -v jboard_prepare_standalone_build_env >/dev/null 2>&1; then jboard_prepare_standalone_build_env "$APP_DIR" jboard_print_standalone_profile else export NEXT_TELEMETRY_DISABLED=1 fi 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 mkdir -p "$APP_DIR/storage" "$APP_DIR/backups" if id "$SERVICE_USER" >/dev/null 2>&1; then run_as_root chown -R "$SERVICE_USER" "$APP_DIR/storage" "$APP_DIR/backups" fi } print_usage() { cat </dev/null 2>&1; then printf '当前版本:%s\n' "$(git rev-parse --short HEAD 2>/dev/null || true)" printf '当前分支:%s\n' "$(git branch --show-current 2>/dev/null || true)" fi echo if [ "$DEPLOY_MODE" = "docker" ]; then docker_compose ps else printf '服务名称:%s\n\n' "$SERVICE_NAME" run_as_root systemctl --no-pager status "$SERVICE_NAME" || true fi } docker_update_cmd() { [ -f "$APP_DIR/scripts/upgrade-jboard-panel.sh" ] || fail "未找到更新脚本:$APP_DIR/scripts/upgrade-jboard-panel.sh" run_as_root env \ APP_DIR="$APP_DIR" \ COMPOSE="$COMPOSE" \ BACKUP="${BACKUP:-1}" \ JBOARD_BUILD_PROFILE="${JBOARD_BUILD_PROFILE:-auto}" \ bash "$APP_DIR/scripts/upgrade-jboard-panel.sh" } standalone_update_cmd() { standalone_backup_sqlite_database echo "[1/5] Pulling latest code..." run_in_app_dir git -c safe.directory="$APP_DIR" pull --ff-only echo "[2/5] Installing dependencies and building..." standalone_build_app echo "[3/5] Restarting service..." standalone_service_ctl restart echo "[4/5] Waiting for app..." sleep 5 echo "[5/5] Service status:" status_cmd } update_cmd() { section "$BRAND 更新" if [ "$DEPLOY_MODE" = "docker" ]; then docker_update_cmd else standalone_update_cmd fi } reset_admin_cmd() { section "重置管理员账号" load_env_for_prompts local email="${1:-${ADMIN_RESET_EMAIL:-}}" local password="${2:-${ADMIN_RESET_PASSWORD:-}}" local name="${3:-${ADMIN_RESET_NAME:-}}" email="${email:-$(prompt_value "管理员邮箱" "${ADMIN_EMAIL:-admin@jboard.local}")}" password="${password:-$(prompt_secret_or_generated "新管理员密码" "$(random_password)")}" name="${name:-$(prompt_value "管理员昵称" "${ADMIN_NAME:-Admin}")}" [ -n "$email" ] || fail "管理员邮箱不能为空。" [ -n "$password" ] || fail "管理员密码不能为空。" if [ "$DEPLOY_MODE" = "docker" ]; then docker_compose --profile setup run --rm \ -e "ADMIN_RESET_EMAIL=$email" \ -e "ADMIN_RESET_PASSWORD=$password" \ -e "ADMIN_RESET_NAME=$name" \ init sh -lc 'npm run db:push && npm run admin:reset && chown -R 1001:1001 /app/storage' else run_in_app_dir npm run db:push run_in_app_dir env \ ADMIN_RESET_EMAIL="$email" \ ADMIN_RESET_PASSWORD="$password" \ ADMIN_RESET_NAME="$name" \ npm run admin:reset run_as_root chown -R "$SERVICE_USER" "$APP_DIR/storage" || true fi echo printf '管理员邮箱:%s\n' "$email" printf '管理员密码:%s\n' "$password" } logs_cmd() { if [ "$DEPLOY_MODE" = "docker" ]; then docker_compose logs -f app else standalone_logs fi } restart_cmd() { section "$BRAND 重启" if [ "$DEPLOY_MODE" = "docker" ]; then docker_compose up -d app else standalone_service_ctl restart fi status_cmd } stop_cmd() { section "$BRAND 停止" if [ "$DEPLOY_MODE" = "docker" ]; then docker_compose stop app else standalone_service_ctl stop fi } start_cmd() { section "$BRAND 启动" if [ "$DEPLOY_MODE" = "docker" ]; then docker_compose up -d app else standalone_service_ctl start fi status_cmd } uninstall_cmd() { local uninstall_script="" if [ "$DEPLOY_MODE" = "docker" ]; then uninstall_script="$APP_DIR/scripts/uninstall-jboard-panel.sh" else uninstall_script="$APP_DIR/scripts/uninstall-jetboard-standalone.sh" fi [ -f "$uninstall_script" ] || fail "未找到卸载脚本:$uninstall_script" local tmp tmp="$(mktemp)" cp "$uninstall_script" "$tmp" chmod +x "$tmp" if [ "$DEPLOY_MODE" = "docker" ]; then run_as_root env \ JBOARD_APP_DIR="$APP_DIR" \ COMPOSE="$COMPOSE" \ JETBOARD_CONFIG="$CONFIG_FILE" \ bash "$tmp" else run_as_root env \ JBOARD_APP_DIR="$APP_DIR" \ JBOARD_SERVICE_NAME="$SERVICE_NAME" \ JBOARD_SERVICE_USER="$SERVICE_USER" \ JETBOARD_CONFIG="$CONFIG_FILE" \ bash "$tmp" fi rm -f "$tmp" } menu_cmd() { if ! is_interactive; then print_usage return fi while true; do section "$BRAND 管理菜单" printf '部署模式:%s\n' "$DEPLOY_MODE" printf '安装目录:%s\n\n' "$APP_DIR" printf ' 1. 查看状态\n' printf ' 2. 更新面板\n' printf ' 3. 重置管理员账号密码\n' printf ' 4. 查看日志\n' printf ' 5. 重启面板\n' printf ' 6. 停止面板\n' printf ' 7. 启动面板\n' printf ' 8. 卸载面板\n' printf ' 0. 退出\n' echo prompt_print "请选择操作 [1]: " local choice="" prompt_read choice || true choice="${choice:-1}" case "$choice" in 1) status_cmd ;; 2) update_cmd ;; 3) reset_admin_cmd ;; 4) logs_cmd ;; 5) restart_cmd ;; 6) stop_cmd ;; 7) start_cmd ;; 8) uninstall_cmd; break ;; 0|q|Q|exit) break ;; *) echo "未知选项:$choice" >&2 ;; esac done } main() { local command="${1:-menu}" if [ "$#" -gt 0 ]; then shift fi case "$command" in menu) menu_cmd ;; status|ps) status_cmd ;; update|upgrade) update_cmd ;; reset|reset-admin) reset_admin_cmd "$@" ;; logs|log) logs_cmd ;; restart) restart_cmd ;; stop) stop_cmd ;; start) start_cmd ;; uninstall|remove) uninstall_cmd ;; help|-h|--help) print_usage ;; *) print_usage; exit 1 ;; esac } main "$@"