From 6d6489817d04ac0be36a12526f72895aaedc890a Mon Sep 17 00:00:00 2001 From: JetSprow Date: Fri, 1 May 2026 01:50:37 +1000 Subject: [PATCH] feat: add jetboard deployment manager --- README.md | 97 +++- package.json | 3 +- scripts/install-jboard-panel.sh | 58 ++- scripts/install-jetboard-standalone.sh | 603 +++++++++++++++++++++++ scripts/jetboard.sh | 531 ++++++++++++++++++++ scripts/lib-standalone-profile.sh | 191 +++++++ scripts/reset-admin.ts | 57 +++ scripts/uninstall-jboard-panel.sh | 174 +++++++ scripts/uninstall-jetboard-standalone.sh | 158 ++++++ 9 files changed, 1854 insertions(+), 18 deletions(-) create mode 100755 scripts/install-jetboard-standalone.sh create mode 100755 scripts/jetboard.sh create mode 100755 scripts/lib-standalone-profile.sh create mode 100644 scripts/reset-admin.ts create mode 100755 scripts/uninstall-jboard-panel.sh create mode 100755 scripts/uninstall-jetboard-standalone.sh diff --git a/README.md b/README.md index ced70eb..743f33d 100644 --- a/README.md +++ b/README.md @@ -99,8 +99,12 @@ J-Board Lite 面板和 Agent 使用相对独立的版本节奏。 | `prisma/schema.prisma` | 数据模型事实源。 | | `data/GeoLite2-City.mmdb` | 默认 GeoIP 城市库。 | | `agent/jboard-agent` | Go Agent 源码、构建脚本和 Agent 文档。 | -| `scripts/install-jboard-panel.sh` | 面板一键安装向导。 | -| `scripts/upgrade-jboard-panel.sh` | 面板升级脚本。 | +| `scripts/install-jetboard-standalone.sh` | 不使用 Docker 的面板一键安装向导。 | +| `scripts/uninstall-jetboard-standalone.sh` | standalone 部署一键卸载脚本。 | +| `scripts/jetboard.sh` | Docker / standalone 共用的 `jetboard` 管理命令入口。 | +| `scripts/install-jboard-panel.sh` | Docker 面板一键安装向导。 | +| `scripts/upgrade-jboard-panel.sh` | Docker 面板升级脚本。 | +| `scripts/uninstall-jboard-panel.sh` | Docker 面板一键卸载脚本。 | | `scripts/install-jboard-agent.sh` | Agent 安装脚本。 | | `scripts/upgrade-jboard-agent.sh` | Agent 升级脚本。 | | `docs/API.md` | HTTP 接口与 Server Actions 参考。 | @@ -121,21 +125,23 @@ J-Board Lite 面板和 Agent 使用相对独立的版本节奏。 | `DATABASE_URL` | SQLite 文件地址 | 本地默认 `file:./storage/jboard.db`;Docker 部署时 Compose 会覆盖为容器内 `/app/storage/jboard.db`。 | | `GEOIP_MMDB_PATH` | GeoIP 城市库 | 默认 `data/GeoLite2-City.mmdb`。可换成自己的 MaxMind City MMDB。 | | `JBOARD_LOG_CLEANUP_SCHEDULER` | 日志清理定时器 | 默认启用。设为 `false` 可关闭进程内自动清理任务。 | -| `ADMIN_EMAIL` / `ADMIN_PASSWORD` / `ADMIN_NAME` | 初始管理员 | 首次 `db:seed` 创建管理员账号。已有数据库不会强制重置旧管理员密码。 | +| `ADMIN_EMAIL` / `ADMIN_PASSWORD` / `ADMIN_NAME` | 初始管理员 | 首次 `db:seed` 创建管理员账号。已有数据库不会强制重置旧管理员密码;忘记密码时执行 `jetboard reset`。 | SMTP 邮件服务、注册邮箱验证开关、支付方式、3x-ui 节点等业务配置在管理后台填写,不建议写进 `.env`。 日志清理在后台“系统设置 -> 日志清理”中配置。默认每天最多自动清理一次 30 天前日志,范围包含审计日志、任务记录、流量日志、节点延迟日志、风控访问日志和风控事件;正在生效的用户端风控限制不会被自动清理。管理员也可以在后台选择日志范围和天数,立即手动清理过期日志。 -## 一键部署 +## Standalone 一键部署 -适合全新 Linux 服务器。脚本会安装基础依赖、安装 Docker 与 Compose 插件、拉取代码、生成 `.env`、初始化数据库并启动面板。 +适合不想使用 Docker 的全新 Linux 服务器。脚本会安装基础依赖、安装 Node.js、拉取代码、生成 `.env`、构建 Next.js standalone 产物、初始化 SQLite,并注册 systemd 服务。 ```bash -bash <(curl -fsSL https://raw.githubusercontent.com/JetSprow/J-Board-Lite/main/scripts/install-jboard-panel.sh) +bash <(curl -fsSL https://raw.githubusercontent.com/JetSprow/J-Board-Lite/main/scripts/install-jetboard-standalone.sh) ``` -安装和更新脚本会自动检测 CPU、宿主机内存、Docker 可用内存和 Docker 数据盘空间。常规机器使用 Docker 默认构建策略;1C、低于 2GB 内存或 Docker 可用空间低于 8GB 的小机器会自动进入低资源模式,降低 Compose 并发、npm 编译并发和 Next 构建堆内存,并把镜像分步构建,构建会更慢但峰值占用更低。 +安装和更新脚本会自动检测 CPU、内存和安装目录可用空间。常规机器使用 npm/Next 默认构建策略;1C、低于 2GB 内存或可用空间低于 8GB 的小机器会自动进入低资源模式,降低 npm 编译并发和 Next 构建堆内存,构建会更慢但峰值占用更低。 + +安装完成后会写入 `/etc/jetboard.conf` 并安装全局命令 `jetboard`。直接执行 `jetboard` 会打开 JetBoard 管理菜单,后续更新、状态查看、日志查看、管理员密码重置和卸载都可以从这里完成。 脚本会交互询问: @@ -152,20 +158,20 @@ bash <(curl -fsSL https://raw.githubusercontent.com/JetSprow/J-Board-Lite/main/s 也可以通过环境变量覆盖默认行为: ```bash -APP_DIR=/opt/jboard GH_REPO=JetSprow/J-Board-Lite BRANCH=main bash <(curl -fsSL https://raw.githubusercontent.com/JetSprow/J-Board-Lite/main/scripts/install-jboard-panel.sh) +APP_DIR=/opt/jboard GH_REPO=JetSprow/J-Board-Lite BRANCH=main bash <(curl -fsSL https://raw.githubusercontent.com/JetSprow/J-Board-Lite/main/scripts/install-jetboard-standalone.sh) ``` 构建资源策略也可以手动覆盖: ```bash # 自动判断,默认值 -JBOARD_BUILD_PROFILE=auto ./scripts/upgrade-jboard-panel.sh +JBOARD_BUILD_PROFILE=auto jetboard update # 强制低资源慢速构建 -JBOARD_BUILD_PROFILE=low ./scripts/upgrade-jboard-panel.sh +JBOARD_BUILD_PROFILE=low jetboard update # 强制常规构建,不额外限制并发或 Node heap -JBOARD_BUILD_PROFILE=normal ./scripts/upgrade-jboard-panel.sh +JBOARD_BUILD_PROFILE=normal jetboard update ``` 脚本完成后会输出: @@ -180,6 +186,75 @@ JBOARD_BUILD_PROFILE=normal ./scripts/upgrade-jboard-panel.sh 请把管理员密码保存到密码管理器。已有数据库重复部署时,脚本会尽量沿用现有配置,不会随意重置管理员。 +## JetBoard 管理命令 + +Docker 和 standalone 一键脚本都会安装全局命令。安装后可在任意目录执行: + +```bash +jetboard +``` + +常用命令: + +```bash +jetboard status # 查看当前部署状态 +jetboard update # 拉取最新代码、备份 SQLite、按机器配置更新 +jetboard reset # 重置或创建管理员账号密码 +jetboard logs # 查看服务日志 +jetboard restart # 重启面板 +jetboard uninstall # 完整卸载当前部署 +``` + +`jetboard` 会读取 `/etc/jetboard.conf` 中的部署模式:Docker 部署会调用 Docker Compose,standalone 部署会调用 systemd 和本机构建。`jetboard update` 和安装脚本使用同一套资源检测逻辑,强机器不额外限制构建;1C1G、小内存或可用空间不足时自动进入低占用慢速构建。忘记管理员账号密码时执行 `jetboard reset`,按提示输入管理员邮箱和新密码;如果该管理员不存在,会自动创建并激活为管理员。 + +完整一键卸载 standalone 部署: + +```bash +bash <(curl -fsSL https://raw.githubusercontent.com/JetSprow/J-Board-Lite/main/scripts/uninstall-jetboard-standalone.sh) +``` + +非交互卸载: + +```bash +UNINSTALL_CONFIRM=YES bash <(curl -fsSL https://raw.githubusercontent.com/JetSprow/J-Board-Lite/main/scripts/uninstall-jetboard-standalone.sh) +``` + +卸载脚本会先备份 `.env`、`storage/` 和 `backups/` 到 `/root/jboard-uninstall-backup-时间.tar.gz`,再删除 systemd 服务、全局命令、`/etc/jetboard.conf` 和安装目录。 + +## Docker 一键部署 + +适合希望用 Docker Compose 隔离运行环境的服务器。脚本会安装基础依赖、安装 Docker 与 Compose 插件、拉取代码、生成 `.env`、初始化数据库并启动面板。 + +```bash +bash <(curl -fsSL https://raw.githubusercontent.com/JetSprow/J-Board-Lite/main/scripts/install-jboard-panel.sh) +``` + +Docker 安装和更新脚本会自动检测 CPU、宿主机内存、Docker 可用内存和 Docker 数据盘空间。常规机器使用 Docker 默认构建策略;1C、低于 2GB 内存或 Docker 可用空间低于 8GB 的小机器会自动进入低资源模式,降低 Compose 并发、npm 编译并发和 Next 构建堆内存,并把镜像分步构建,构建会更慢但峰值占用更低。 + +Docker 一键脚本也会安装 `jetboard` 全局命令,并写入 Docker 部署模式。常用操作: + +```bash +jetboard status # docker compose ps +jetboard update # 拉取代码、备份 SQLite、按机器配置重建镜像 +jetboard reset # 通过 init 容器重置或创建管理员 +jetboard logs # docker compose logs -f app +jetboard uninstall # 备份后删除 Compose 服务、volume 和安装目录 +``` + +完整一键卸载 Docker 部署: + +```bash +bash <(curl -fsSL https://raw.githubusercontent.com/JetSprow/J-Board-Lite/main/scripts/uninstall-jboard-panel.sh) +``` + +非交互卸载: + +```bash +UNINSTALL_CONFIRM=YES bash <(curl -fsSL https://raw.githubusercontent.com/JetSprow/J-Board-Lite/main/scripts/uninstall-jboard-panel.sh) +``` + +卸载脚本会先备份 `.env`、`backups/` 和 Docker SQLite volume,root 执行时保存到 `/root/jboard-docker-uninstall-backup-时间.tar.gz`,非 root 直接执行时保存到当前用户 HOME;随后执行 `docker compose down --volumes --remove-orphans`,最后删除全局命令、`/etc/jetboard.conf` 和安装目录。Docker 本身不会被删除。 + ## 手动 Docker 部署 首次启动: diff --git a/package.json b/package.json index f618880..89e5cec 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "start": "next start", "lint": "eslint", "db:seed": "tsx prisma/seed.ts", - "db:push": "prisma db push --accept-data-loss" + "db:push": "prisma db push --accept-data-loss", + "admin:reset": "tsx scripts/reset-admin.ts" }, "dependencies": { "@base-ui/react": "^1.4.1", diff --git a/scripts/install-jboard-panel.sh b/scripts/install-jboard-panel.sh index e748a5c..1e2a870 100755 --- a/scripts/install-jboard-panel.sh +++ b/scripts/install-jboard-panel.sh @@ -315,6 +315,49 @@ write_env() { rm -f "$tmp" } +install_jetboard_command() { + section "安装 JetBoard 管理命令" + + if [ ! -f "$APP_DIR/scripts/jetboard.sh" ]; then + echo "未找到 $APP_DIR/scripts/jetboard.sh,跳过管理命令安装。" + return + fi + + local config_tmp wrapper_tmp + config_tmp="$(mktemp)" + wrapper_tmp="$(mktemp)" + + { + printf '# JetBoard Docker command configuration\n' + printf 'JBOARD_DEPLOY_MODE="docker"\n' + printf 'JBOARD_APP_DIR="%s"\n' "$(env_escape "$APP_DIR")" + printf 'GH_REPO="%s"\n' "$(env_escape "$GH_REPO")" + printf 'BRANCH="%s"\n' "$(env_escape "$BRANCH")" + printf 'COMPOSE="docker compose"\n' + } > "$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" /etc/jetboard.conf + 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)" +} + configure_env() { section "生成 .env 配置" @@ -416,12 +459,12 @@ 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="沿用已有数据库账号;如忘记请在数据库中重置" + shown_password="沿用已有数据库账号;如忘记可执行 jetboard reset" fi echo printf '%s\n' "============================================================" - printf '%s\n' "J-Board 部署完成" + printf '%s\n' "J-Board Lite Docker 部署完成" 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}}}" @@ -446,10 +489,12 @@ print_summary() { printf ' 4. 后台 /admin/plans:创建套餐并绑定入站或流媒体服务。\n' echo printf '%s\n' "常用命令" - printf ' cd %s\n' "$APP_DIR" - printf ' sudo docker compose logs -f app\n' - printf ' sudo docker compose ps\n' - printf ' sudo ./scripts/upgrade-jboard-panel.sh\n' + printf ' jetboard # 打开 JetBoard 管理菜单\n' + printf ' jetboard status # 查看 Docker 服务状态\n' + printf ' jetboard update # 拉取代码、备份数据库并按机器配置重建\n' + printf ' jetboard reset # 重置或创建管理员账号密码\n' + printf ' jetboard logs # 查看 app 日志\n' + printf ' jetboard uninstall # 完整卸载 Docker 部署\n' printf '%s\n' "============================================================" } @@ -462,6 +507,7 @@ main() { prepare_repo load_resource_helpers configure_env + install_jetboard_command install_docker start_panel wait_for_app diff --git a/scripts/install-jetboard-standalone.sh b/scripts/install-jetboard-standalone.sh new file mode 100755 index 0000000..cb76acc --- /dev/null +++ b/scripts/install-jetboard-standalone.sh @@ -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 "$@" diff --git a/scripts/jetboard.sh b/scripts/jetboard.sh new file mode 100755 index 0000000..bd6af3d --- /dev/null +++ b/scripts/jetboard.sh @@ -0,0 +1,531 @@ +#!/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 "$@" diff --git a/scripts/lib-standalone-profile.sh b/scripts/lib-standalone-profile.sh new file mode 100755 index 0000000..b58a14c --- /dev/null +++ b/scripts/lib-standalone-profile.sh @@ -0,0 +1,191 @@ +#!/usr/bin/env bash + +# Shared profile detection for non-Docker installs and updates. +# Strong machines use the default npm/Next behavior. Small machines trade time +# for lower peak CPU and memory usage. + +JBOARD_STANDALONE_PROFILE_RESOLVED="" +JBOARD_CPU_COUNT="" +JBOARD_HOST_MEM_MB="" +JBOARD_APP_DISK_AVAIL_MB="" +JBOARD_BUILD_NODE_HEAP_MB="" +JBOARD_RUNTIME_NODE_HEAP_MB="" +JBOARD_RUNTIME_NODE_OPTIONS="" + +jboard_cpu_count() { + local value="" + + if command -v nproc >/dev/null 2>&1; then + value="$(nproc 2>/dev/null || true)" + elif command -v getconf >/dev/null 2>&1; then + value="$(getconf _NPROCESSORS_ONLN 2>/dev/null || true)" + elif command -v sysctl >/dev/null 2>&1; then + value="$(sysctl -n hw.ncpu 2>/dev/null || true)" + fi + + case "$value" in + ''|*[!0-9]*) echo 1 ;; + *) echo "$value" ;; + esac +} + +jboard_host_mem_mb() { + local value="" + + if [ -r /proc/meminfo ]; then + value="$(awk '/^MemTotal:/ {print int($2 / 1024)}' /proc/meminfo 2>/dev/null || true)" + elif command -v getconf >/dev/null 2>&1; then + local pages page_size + pages="$(getconf _PHYS_PAGES 2>/dev/null || true)" + page_size="$(getconf PAGE_SIZE 2>/dev/null || true)" + if [ -n "$pages" ] && [ -n "$page_size" ]; then + value="$((pages * page_size / 1024 / 1024))" + fi + elif command -v sysctl >/dev/null 2>&1; then + local bytes + bytes="$(sysctl -n hw.memsize 2>/dev/null || true)" + if [ -n "$bytes" ]; then + value="$((bytes / 1024 / 1024))" + fi + fi + + case "$value" in + ''|*[!0-9]*) echo 0 ;; + *) echo "$value" ;; + esac +} + +jboard_path_avail_mb() { + local path="$1" + local value="" + + if [ -n "$path" ]; then + value="$(df -Pm "$path" 2>/dev/null | awk 'NR == 2 {print $4}' || true)" + fi + + case "$value" in + ''|*[!0-9]*) echo 0 ;; + *) echo "$value" ;; + esac +} + +jboard_standalone_build_heap_mb() { + local mem_mb="$1" + + if [ -n "${JBOARD_LOW_RESOURCE_NODE_MB:-}" ]; then + echo "$JBOARD_LOW_RESOURCE_NODE_MB" + return + fi + + if [ "$mem_mb" -gt 0 ] && [ "$mem_mb" -le 1200 ]; then + echo 640 + elif [ "$mem_mb" -gt 0 ] && [ "$mem_mb" -le 1700 ]; then + echo 768 + elif [ "$mem_mb" -gt 0 ] && [ "$mem_mb" -le 2400 ]; then + echo 1024 + else + echo 1536 + fi +} + +jboard_standalone_runtime_heap_mb() { + local mem_mb="$1" + + if [ -n "${JBOARD_RUNTIME_NODE_MB:-}" ]; then + echo "$JBOARD_RUNTIME_NODE_MB" + return + fi + + if [ "$mem_mb" -gt 0 ] && [ "$mem_mb" -le 1200 ]; then + echo 512 + elif [ "$mem_mb" -gt 0 ] && [ "$mem_mb" -le 1700 ]; then + echo 768 + elif [ "$mem_mb" -gt 0 ] && [ "$mem_mb" -le 2400 ]; then + echo 1024 + else + echo "" + fi +} + +jboard_resolve_standalone_profile() { + local requested="${JBOARD_BUILD_PROFILE:-auto}" + local cpu="$1" + local mem_mb="$2" + local disk_mb="$3" + + case "$requested" in + low|slow|small) + echo low + return + ;; + normal|fast|strong) + echo normal + return + ;; + auto|'') + ;; + *) + echo "未知 JBOARD_BUILD_PROFILE=$requested,回退为 auto。" >&2 + ;; + esac + + if [ "$cpu" -le 1 ]; then + echo low + elif [ "$mem_mb" -gt 0 ] && [ "$mem_mb" -lt 2048 ]; then + echo low + elif [ "$disk_mb" -gt 0 ] && [ "$disk_mb" -lt 8192 ]; then + echo low + else + echo normal + fi +} + +jboard_prepare_standalone_build_env() { + local app_dir="${1:-.}" + + JBOARD_CPU_COUNT="$(jboard_cpu_count)" + JBOARD_HOST_MEM_MB="$(jboard_host_mem_mb)" + JBOARD_APP_DISK_AVAIL_MB="$(jboard_path_avail_mb "$app_dir")" + JBOARD_STANDALONE_PROFILE_RESOLVED="$(jboard_resolve_standalone_profile "$JBOARD_CPU_COUNT" "$JBOARD_HOST_MEM_MB" "$JBOARD_APP_DISK_AVAIL_MB")" + + export NEXT_TELEMETRY_DISABLED=1 + + if [ "$JBOARD_STANDALONE_PROFILE_RESOLVED" = "low" ]; then + JBOARD_BUILD_NODE_HEAP_MB="$(jboard_standalone_build_heap_mb "$JBOARD_HOST_MEM_MB")" + JBOARD_RUNTIME_NODE_HEAP_MB="$(jboard_standalone_runtime_heap_mb "$JBOARD_HOST_MEM_MB")" + JBOARD_RUNTIME_NODE_OPTIONS="--max-old-space-size=${JBOARD_RUNTIME_NODE_HEAP_MB}" + export NPM_CONFIG_JOBS="${NPM_CONFIG_JOBS:-1}" + export npm_config_jobs="${npm_config_jobs:-$NPM_CONFIG_JOBS}" + export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=${JBOARD_BUILD_NODE_HEAP_MB}}" + else + JBOARD_BUILD_NODE_HEAP_MB="" + JBOARD_RUNTIME_NODE_HEAP_MB="" + JBOARD_RUNTIME_NODE_OPTIONS="${JBOARD_RUNTIME_NODE_OPTIONS:-}" + export NPM_CONFIG_JOBS="${NPM_CONFIG_JOBS:-}" + fi +} + +jboard_is_low_resource_standalone() { + [ "${JBOARD_STANDALONE_PROFILE_RESOLVED:-}" = "low" ] +} + +jboard_print_standalone_profile() { + local disk="${JBOARD_APP_DISK_AVAIL_MB:-0}" + local disk_text="unknown" + + if [ "$disk" -gt 0 ]; then + disk_text="${disk}MB" + fi + + if jboard_is_low_resource_standalone; then + echo "检测到低资源本机环境:CPU=${JBOARD_CPU_COUNT:-?},内存=${JBOARD_HOST_MEM_MB:-0}MB,可用空间=${disk_text}" + echo "启用慢速低占用模式:npm jobs=${NPM_CONFIG_JOBS:-1},构建 Node heap=${JBOARD_BUILD_NODE_HEAP_MB:-?}MB,运行 Node heap=${JBOARD_RUNTIME_NODE_HEAP_MB:-?}MB。" + else + echo "检测到常规本机环境:CPU=${JBOARD_CPU_COUNT:-?},内存=${JBOARD_HOST_MEM_MB:-0}MB,可用空间=${disk_text}" + echo "使用 npm/Next 默认构建策略,不额外限制并发或 Node heap。" + fi + + if [ "$disk" -gt 0 ] && [ "$disk" -lt 8192 ]; then + echo "提示:可用空间低于 8GB,建议扩容或清理缓存后再构建。" + fi +} diff --git a/scripts/reset-admin.ts b/scripts/reset-admin.ts new file mode 100644 index 0000000..ed27d07 --- /dev/null +++ b/scripts/reset-admin.ts @@ -0,0 +1,57 @@ +import "dotenv/config"; +import { PrismaClient } from "@prisma/client"; +import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3"; +import bcrypt from "bcryptjs"; + +const adapter = new PrismaBetterSqlite3({ + url: process.env.DATABASE_URL || "file:./storage/jboard.db", +}); +const prisma = new PrismaClient({ adapter }); + +function envValue(key: string, fallback = "") { + return process.env[key]?.trim() || fallback; +} + +async function main() { + const email = envValue("ADMIN_RESET_EMAIL", envValue("ADMIN_EMAIL", "admin@jboard.local")).toLowerCase(); + const password = envValue("ADMIN_RESET_PASSWORD", envValue("ADMIN_PASSWORD")); + const name = envValue("ADMIN_RESET_NAME", envValue("ADMIN_NAME", "Admin")); + + if (!email) { + throw new Error("ADMIN_RESET_EMAIL is required."); + } + + if (!password || password.length < 6) { + throw new Error("ADMIN_RESET_PASSWORD must be at least 6 characters."); + } + + const hashedPassword = await bcrypt.hash(password, 12); + + await prisma.user.upsert({ + where: { email }, + update: { + password: hashedPassword, + name, + role: "ADMIN", + status: "ACTIVE", + emailVerifiedAt: new Date(), + }, + create: { + email, + password: hashedPassword, + name, + role: "ADMIN", + status: "ACTIVE", + emailVerifiedAt: new Date(), + }, + }); + + console.log(`Admin reset completed: ${email}`); +} + +main() + .catch((error) => { + console.error(error); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/scripts/uninstall-jboard-panel.sh b/scripts/uninstall-jboard-panel.sh new file mode 100755 index 0000000..a58fae3 --- /dev/null +++ b/scripts/uninstall-jboard-panel.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONFIG_FILE="${JETBOARD_CONFIG:-/etc/jetboard.conf}" +APP_DIR="${JBOARD_APP_DIR:-${APP_DIR:-}}" +COMPOSE="${COMPOSE:-docker compose}" +if [ -z "${BACKUP_ROOT:-}" ]; then + if [ "$(id -u)" -eq 0 ]; then + BACKUP_ROOT="/root" + else + BACKUP_ROOT="${HOME:-/tmp}" + fi +fi +UNINSTALL_CONFIRM="${UNINSTALL_CONFIRM:-}" + +if [ -z "$APP_DIR" ] && [ -r "$CONFIG_FILE" ]; then + # shellcheck disable=SC1090 + . "$CONFIG_FILE" + APP_DIR="${JBOARD_APP_DIR:-${APP_DIR:-}}" + COMPOSE="${COMPOSE:-docker compose}" +fi + +APP_DIR="${APP_DIR:-/opt/jboard}" + +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 +} + +compose() { + if [ "$(id -u)" -eq 0 ]; then + $COMPOSE "$@" + else + run_as_root $COMPOSE "$@" + fi +} + +line() { + printf '%s\n' "------------------------------------------------------------" +} + +section() { + echo + line + printf '%s\n' "$1" + line +} + +confirm_uninstall() { + if [ "$UNINSTALL_CONFIRM" = "YES" ]; then + return + fi + + if ! is_interactive; then + echo "非交互卸载请设置 UNINSTALL_CONFIRM=YES。" >&2 + exit 1 + fi + + section "确认卸载 J-Board Lite Docker 部署" + printf '将停止并删除 Docker Compose 服务和 SQLite volume。\n' + printf '将删除安装目录:%s\n' "$APP_DIR" + printf '将删除命令:/usr/local/bin/jetboard、/usr/local/bin/JetBoard\n' + printf '将删除配置:%s\n' "$CONFIG_FILE" + echo "卸载前会尝试备份 .env、backups 和 SQLite 数据库到 ${BACKUP_ROOT}。" + echo + prompt_print "请输入 YES 确认卸载: " + local answer="" + prompt_read answer || true + if [ "$answer" != "YES" ]; then + echo "已取消卸载。" + exit 0 + fi +} + +backup_before_remove() { + if [ ! -d "$APP_DIR" ]; then + echo "安装目录不存在,跳过备份:$APP_DIR" + return + fi + + local backup_dir backup_file sqlite_backup + backup_dir="${BACKUP_ROOT%/}/jboard-docker-uninstall-backup-$(date +%F-%H%M%S)" + backup_file="${backup_dir}.tar.gz" + sqlite_backup="$backup_dir/sqlite-storage.tar.gz" + + run_as_root mkdir -p "$backup_dir" + if [ "$(id -u)" -ne 0 ]; then + run_as_root chown "$(id -u):$(id -g)" "$backup_dir" + fi + if [ -f "$APP_DIR/.env" ]; then + run_as_root cp "$APP_DIR/.env" "$backup_dir/.env" + fi + if [ -d "$APP_DIR/backups" ]; then + run_as_root cp -R "$APP_DIR/backups" "$backup_dir/backups" + fi + + if [ -f "$APP_DIR/docker-compose.yml" ]; then + local backup_cmd='cd /app/storage 2>/dev/null && if [ -f jboard.db ]; then set -- jboard.db; [ -f jboard.db-wal ] && set -- "$@" jboard.db-wal; [ -f jboard.db-shm ] && set -- "$@" jboard.db-shm; tar -czf - "$@"; fi' + ( + cd "$APP_DIR" + if compose ps --services --filter status=running 2>/dev/null | grep -qx app; then + compose exec -T app sh -lc "$backup_cmd" > "$sqlite_backup" || true + fi + if [ ! -s "$sqlite_backup" ]; then + compose --profile setup run --rm -T --entrypoint sh init -lc "$backup_cmd" > "$sqlite_backup" || true + fi + ) + if [ ! -s "$sqlite_backup" ]; then + rm -f "$sqlite_backup" + echo "未找到可备份的 SQLite volume,跳过数据库备份。" + fi + fi + + run_as_root tar -C "$(dirname "$backup_dir")" -czf "$backup_file" "$(basename "$backup_dir")" + run_as_root rm -rf "$backup_dir" + echo "卸载备份已保存:$backup_file" +} + +remove_compose_stack() { + section "删除 Docker Compose 服务" + if [ -f "$APP_DIR/docker-compose.yml" ]; then + ( + cd "$APP_DIR" + compose down --volumes --remove-orphans || true + ) + fi +} + +remove_files() { + section "删除文件" + cd / + run_as_root rm -f /usr/local/bin/jetboard /usr/local/bin/JetBoard + run_as_root rm -f "$CONFIG_FILE" + run_as_root rm -rf "$APP_DIR" +} + +main() { + confirm_uninstall + backup_before_remove + remove_compose_stack + remove_files + + echo + echo "J-Board Lite Docker 部署已卸载。Docker 本身不会被删除。" +} + +main "$@" diff --git a/scripts/uninstall-jetboard-standalone.sh b/scripts/uninstall-jetboard-standalone.sh new file mode 100755 index 0000000..8cbc445 --- /dev/null +++ b/scripts/uninstall-jetboard-standalone.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONFIG_FILE="${JETBOARD_CONFIG:-/etc/jetboard.conf}" +APP_DIR="${JBOARD_APP_DIR:-${APP_DIR:-}}" +SERVICE_NAME="${JBOARD_SERVICE_NAME:-jetboard}" +SERVICE_USER="${JBOARD_SERVICE_USER:-jetboard}" +BACKUP_ROOT="${BACKUP_ROOT:-/root}" +UNINSTALL_CONFIRM="${UNINSTALL_CONFIRM:-}" + +if [ -z "$APP_DIR" ] && [ -r "$CONFIG_FILE" ]; then + # shellcheck disable=SC1090 + . "$CONFIG_FILE" + APP_DIR="${JBOARD_APP_DIR:-${APP_DIR:-}}" + SERVICE_NAME="${JBOARD_SERVICE_NAME:-${SERVICE_NAME:-jetboard}}" + SERVICE_USER="${JBOARD_SERVICE_USER:-${SERVICE_USER:-jetboard}}" +fi + +APP_DIR="${APP_DIR:-/opt/jboard}" + +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 +} + +confirm_uninstall() { + if [ "$UNINSTALL_CONFIRM" = "YES" ]; then + return + fi + + if ! is_interactive; then + echo "非交互卸载请设置 UNINSTALL_CONFIRM=YES。" >&2 + exit 1 + fi + + section "确认卸载 J-Board Lite standalone" + printf '将停止并删除服务:%s\n' "$SERVICE_NAME" + printf '将删除安装目录:%s\n' "$APP_DIR" + printf '将删除命令:/usr/local/bin/jetboard、/usr/local/bin/JetBoard\n' + printf '将删除配置:%s\n' "$CONFIG_FILE" + echo "卸载前会尝试备份 .env 和 storage 到 ${BACKUP_ROOT}。" + echo + prompt_print "请输入 YES 确认卸载: " + local answer="" + prompt_read answer || true + if [ "$answer" != "YES" ]; then + echo "已取消卸载。" + exit 0 + fi +} + +backup_before_remove() { + if [ ! -d "$APP_DIR" ]; then + echo "安装目录不存在,跳过备份:$APP_DIR" + return + fi + + local backup_dir backup_file + backup_dir="${BACKUP_ROOT%/}/jboard-uninstall-backup-$(date +%F-%H%M%S)" + backup_file="${backup_dir}.tar.gz" + + run_as_root mkdir -p "$backup_dir" + if [ -f "$APP_DIR/.env" ]; then + run_as_root cp "$APP_DIR/.env" "$backup_dir/.env" + fi + if [ -d "$APP_DIR/storage" ]; then + run_as_root cp -R "$APP_DIR/storage" "$backup_dir/storage" + fi + if [ -d "$APP_DIR/backups" ]; then + run_as_root cp -R "$APP_DIR/backups" "$backup_dir/backups" + fi + + run_as_root tar -C "$(dirname "$backup_dir")" -czf "$backup_file" "$(basename "$backup_dir")" + run_as_root rm -rf "$backup_dir" + echo "卸载备份已保存:$backup_file" +} + +remove_service() { + section "删除 systemd 服务" + if command -v systemctl >/dev/null 2>&1; then + run_as_root systemctl stop "$SERVICE_NAME" >/dev/null 2>&1 || true + run_as_root systemctl disable "$SERVICE_NAME" >/dev/null 2>&1 || true + fi + run_as_root rm -f "/etc/systemd/system/${SERVICE_NAME}.service" + if command -v systemctl >/dev/null 2>&1; then + run_as_root systemctl daemon-reload || true + run_as_root systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true + fi +} + +remove_files() { + section "删除文件" + run_as_root rm -f /usr/local/bin/jetboard /usr/local/bin/JetBoard + run_as_root rm -f "$CONFIG_FILE" + run_as_root rm -rf "$APP_DIR" +} + +remove_user() { + section "删除运行用户" + if id "$SERVICE_USER" >/dev/null 2>&1; then + if command -v userdel >/dev/null 2>&1; then + run_as_root userdel "$SERVICE_USER" >/dev/null 2>&1 || true + elif command -v deluser >/dev/null 2>&1; then + run_as_root deluser "$SERVICE_USER" >/dev/null 2>&1 || true + fi + fi +} + +main() { + confirm_uninstall + backup_before_remove + remove_service + remove_files + remove_user + + echo + echo "J-Board Lite standalone 已卸载。" +} + +main "$@"