跳转至主要内容

PocketBase + Caddy 安装和快速添加项目的一键脚本

— dokbun

PocketBase 非常适合个人小项目,AI 编程场景下的后端用户注册登录、数据和文件存储必备。

PocketBase 视频教程推荐

这个脚本可以快速安装 PocketBase 和 Caddy,并实现快速添加多个 PocketBase 和绑定域名,自动申请 SSL 证书,以便用于不同的项目。提前解析域名之后,运行脚本即可开始使用。

创建脚本

vim pocketbase.sh
#!/bin/bash

set -euo pipefail

# ==============================================================================
# 脚本名称: install_pocketbase.sh
# 描述:     在 Ubuntu VPS 上安装 PocketBase 和 Caddy,并管理多个项目实例。
# 作者:     dokbun
# ==============================================================================

# 颜色定义
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color

DEFAULT_PB_VERSION="0.38.2"
PB_REPO_URL="https://github.com/pocketbase/pocketbase"
PB_RELEASES_URL="https://github.com/pocketbase/pocketbase/releases"
PB_SYSTEM_USER="pocketbase"
PB_SYSTEM_GROUP="pocketbase"
PB_PROJECTS_DIR="/opt/pocketbase"
CADDYFILE="/etc/caddy/Caddyfile"

# 注意:直接以 root 运行脚本不是最佳实践。
# 脚本中已为需要 root 权限的命令添加了 sudo,因此请以普通用户运行,
# 它会在需要时提示您输入 sudo 密码。

error() {
    echo -e "${RED}错误: $*${NC}" >&2
}

info() {
    echo -e "${GREEN}$*${NC}"
}

warn() {
    echo -e "${YELLOW}$*${NC}" >&2
}

validate_version() {
    local version="$1"
    [[ "$version" =~ ^[0-9]+(\.[0-9]+){1,3}$ ]]
}

validate_project_name() {
    local project_name="$1"
    [[ "$project_name" =~ ^[a-zA-Z0-9_-]+$ ]]
}

validate_port() {
    local port="$1"
    [[ "$port" =~ ^[0-9]+$ ]] && [ "$port" -ge 1 ] && [ "$port" -le 65535 ]
}

validate_domain() {
    local domain="$1"
    [[ "$domain" =~ ^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$ ]]
}

validate_email() {
    local email="$1"
    [[ "$email" =~ ^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$ ]]
}

detect_pocketbase_arch() {
    case "$(uname -m)" in
        x86_64|amd64)
            echo "amd64"
            ;;
        aarch64|arm64)
            echo "arm64"
            ;;
        armv7l|armv7)
            echo "armv7"
            ;;
        *)
            error "不支持的 CPU 架构: $(uname -m)"
            return 1
            ;;
    esac
}

install_dependencies() {
    warn "正在检查/安装必要依赖..."
    sudo apt update
    sudo apt install -y unzip wget curl gpg ca-certificates debian-keyring debian-archive-keyring apt-transport-https ufw
    info "依赖安装完成。"
}

ensure_pocketbase_user() {
    if ! getent group "$PB_SYSTEM_GROUP" >/dev/null 2>&1; then
        warn "正在创建专用系统用户组 ${PB_SYSTEM_GROUP}..."
        sudo groupadd --system "$PB_SYSTEM_GROUP"
    fi

    if id -u "$PB_SYSTEM_USER" >/dev/null 2>&1; then
        info "系统用户 ${PB_SYSTEM_USER} 已存在。"
        return 0
    fi

    warn "正在创建专用系统用户 ${PB_SYSTEM_USER}..."
    sudo useradd --system --gid "$PB_SYSTEM_GROUP" --home-dir /var/lib/pocketbase --shell /usr/sbin/nologin "$PB_SYSTEM_USER"
    info "系统用户 ${PB_SYSTEM_USER} 创建完成。"
}

ensure_projects_dir() {
    warn "正在准备 PocketBase 实例目录: ${PB_PROJECTS_DIR}"
    sudo install -d -o "$PB_SYSTEM_USER" -g "$PB_SYSTEM_GROUP" -m 0750 "$PB_PROJECTS_DIR"
}

prompt_pocketbase_version_and_download() {
    echo
    warn "PocketBase GitHub 仓库: ${PB_REPO_URL}"
    warn "PocketBase GitHub Releases: ${PB_RELEASES_URL}"
    echo

    read -r -p "请输入 PocketBase 版本号 (回车默认 ${DEFAULT_PB_VERSION}): " PB_VERSION
    PB_VERSION=${PB_VERSION:-"$DEFAULT_PB_VERSION"}

    if ! validate_version "$PB_VERSION"; then
        error "版本号格式不正确,例如: 0.35.0"
        return 1
    fi

    PB_ARCH=$(detect_pocketbase_arch)
    PB_ZIP_FILE="pocketbase_${PB_VERSION}_linux_${PB_ARCH}.zip"
    PB_DOWNLOAD_URL="https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/${PB_ZIP_FILE}"
    PB_ZIP_PATH="$HOME/${PB_ZIP_FILE}"

    if [ -s "$PB_ZIP_PATH" ]; then
        info "已找到本地安装包,将复用: ${PB_ZIP_PATH}"
        return 0
    fi

    warn "正在从 ${PB_DOWNLOAD_URL} 下载..."
    if ! wget --https-only -O "${PB_ZIP_PATH}.tmp" "$PB_DOWNLOAD_URL"; then
        rm -f "${PB_ZIP_PATH}.tmp"
        error "下载失败! 请检查版本号、CPU 架构或网络连接。"
        return 1
    fi

    if [ ! -s "${PB_ZIP_PATH}.tmp" ]; then
        rm -f "${PB_ZIP_PATH}.tmp"
        error "下载文件为空,请检查版本号或网络连接。"
        return 1
    fi

    mv "${PB_ZIP_PATH}.tmp" "$PB_ZIP_PATH"
    info "PocketBase v${PB_VERSION} (${PB_ARCH}) 下载完成,已保存至 ${PB_ZIP_PATH}"
}

install_caddy() {
    if command -v caddy >/dev/null 2>&1 && sudo systemctl list-unit-files caddy.service --no-legend 2>/dev/null | awk '{print $1}' | grep -qx 'caddy.service'; then
        info "检测到 Caddy 已安装,将保留现有安装。"
        return 0
    fi

    warn "正在配置 Caddy 官方软件源并安装 Caddy..."
    sudo install -d -m 0755 /usr/share/keyrings
    curl -fsSL 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor --yes -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
    curl -fsSL 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list > /dev/null
    sudo apt update
    sudo apt install -y caddy
    info "Caddy 安装完成。"
}

ensure_caddy_available() {
    if command -v caddy >/dev/null 2>&1 && sudo systemctl list-unit-files caddy.service --no-legend 2>/dev/null | awk '{print $1}' | grep -qx 'caddy.service'; then
        return 0
    fi

    warn "未检测到可用的 Caddy 命令和 systemd 服务,将先安装 Caddy。"
    install_caddy
}

backup_caddyfile() {
    local backup_path=""

    if sudo test -f "$CADDYFILE"; then
        backup_path="${CADDYFILE}.bak.$(date +%Y%m%d%H%M%S)"
        sudo cp "$CADDYFILE" "$backup_path"
        warn "已备份当前 Caddyfile: ${backup_path}"
    fi

    echo "$backup_path"
}

restore_caddyfile() {
    local backup_path="$1"

    if [ -n "$backup_path" ] && sudo test -f "$backup_path"; then
        warn "正在恢复 Caddyfile 备份: ${backup_path}"
        sudo cp "$backup_path" "$CADDYFILE"
    fi
}

validate_caddyfile_or_restore() {
    local backup_path="$1"

    if sudo caddy validate --config "$CADDYFILE"; then
        return 0
    fi

    error "Caddyfile 验证失败,已取消本次配置变更。"
    restore_caddyfile "$backup_path"
    return 1
}

format_caddyfile_or_restore() {
    local backup_path="$1"

    if sudo caddy fmt --overwrite "$CADDYFILE"; then
        return 0
    fi

    error "Caddyfile 格式化失败,已取消本次配置变更。"
    restore_caddyfile "$backup_path"
    return 1
}

apply_caddy_or_restore() {
    local backup_path="$1"

    if sudo systemctl is-active --quiet caddy; then
        if sudo systemctl reload caddy; then
            return 0
        fi
    else
        warn "Caddy 当前未运行,正在启用并启动 Caddy..."
        if sudo systemctl enable caddy && sudo systemctl start caddy; then
            return 0
        fi
    fi

    error "Caddy 应用配置失败,正在回滚 Caddyfile。"
    restore_caddyfile "$backup_path"
    if sudo systemctl is-active --quiet caddy; then
        sudo systemctl reload caddy || true
    fi
    return 1
}

ensure_caddy_email() {
    local caddy_email="$1"
    local backup_path
    local tmp_file

    backup_path=$(backup_caddyfile)

    if ! sudo test -s "$CADDYFILE"; then
        warn "正在创建新的 ${CADDYFILE}..."
        sudo tee "$CADDYFILE" > /dev/null <<EOF
{
  email $caddy_email
}
EOF
    elif sudo grep -Eq '^[[:space:]]*email[[:space:]]+' "$CADDYFILE"; then
        warn "Caddyfile 中已存在 email 配置,将保留现有配置。"
    elif sudo awk 'NF && $1 !~ /^#/ { exit ($0 ~ /^[[:space:]]*\{[[:space:]]*$/ ? 0 : 1) }' "$CADDYFILE"; then
        warn "检测到现有 Caddy 全局配置块,正在写入 email..."
        tmp_file=$(mktemp)
        sudo awk -v caddy_email="$caddy_email" '
            !inserted && $0 ~ /^[[:space:]]*\{[[:space:]]*$/ {
                print
                print "  email " caddy_email
                inserted = 1
                next
            }
            { print }
        ' "$CADDYFILE" > "$tmp_file"
        sudo cp "$tmp_file" "$CADDYFILE"
        rm -f "$tmp_file"
    else
        warn "正在将 email 全局配置添加到现有 Caddyfile 顶部..."
        tmp_file=$(mktemp)
        {
            printf '{\n  email %s\n}\n\n' "$caddy_email"
            sudo cat "$CADDYFILE"
        } > "$tmp_file"
        sudo cp "$tmp_file" "$CADDYFILE"
        rm -f "$tmp_file"
    fi

    format_caddyfile_or_restore "$backup_path"
    validate_caddyfile_or_restore "$backup_path"
    apply_caddy_or_restore "$backup_path"
    info "Caddyfile 配置完成。"
}

append_caddy_site() {
    local domain_name="$1"
    local pb_port="$2"
    local backup_path

    if caddy_site_exists "$domain_name"; then
        error "Caddyfile 中已存在域名配置: ${domain_name}"
        return 1
    fi

    backup_path=$(backup_caddyfile)
    warn "正在将域名配置追加到 ${CADDYFILE}..."
    sudo tee -a "$CADDYFILE" > /dev/null <<EOF

${domain_name} {
  reverse_proxy 127.0.0.1:${pb_port}
}
EOF

    format_caddyfile_or_restore "$backup_path"
    validate_caddyfile_or_restore "$backup_path"
    apply_caddy_or_restore "$backup_path"
    info "Caddyfile 配置已验证并重载。"
}

remove_caddy_site() {
    local domain_name="$1"
    local backup_path
    local tmp_file

    if ! sudo grep -Fq "${domain_name} {" "$CADDYFILE" 2>/dev/null; then
        warn "Caddyfile 中未找到域名配置: ${domain_name},跳过 Caddy 配置删除。"
        return 0
    fi

    backup_path=$(backup_caddyfile)
    tmp_file=$(mktemp)

    warn "正在从 ${CADDYFILE} 删除域名配置: ${domain_name}"
    sudo awk -v domain="$domain_name" '
        function count_char(str, char,    i, count) {
            count = 0
            for (i = 1; i <= length(str); i++) {
                if (substr(str, i, 1) == char) {
                    count++
                }
            }
            return count
        }

        $0 ~ "^[[:space:]]*" domain "[[:space:]]*\\{" {
            skipping = 1
            depth = count_char($0, "{") - count_char($0, "}")
            if (depth <= 0) {
                skipping = 0
            }
            next
        }

        skipping {
            depth += count_char($0, "{") - count_char($0, "}")
            if (depth <= 0) {
                skipping = 0
            }
            next
        }

        { print }
    ' "$CADDYFILE" > "$tmp_file"

    sudo cp "$tmp_file" "$CADDYFILE"
    rm -f "$tmp_file"

    format_caddyfile_or_restore "$backup_path"
    validate_caddyfile_or_restore "$backup_path"
    apply_caddy_or_restore "$backup_path"
    info "Caddy 域名配置已删除并应用。"
}

is_port_in_use() {
    local port="$1"

    if command -v ss >/dev/null 2>&1; then
        ss -ltn | awk '{print $4}' | grep -Eq "(^|[:.])${port}$"
    elif command -v lsof >/dev/null 2>&1; then
        lsof -iTCP:"$port" -sTCP:LISTEN -Pn >/dev/null 2>&1
    else
        return 1
    fi
}

find_available_port() {
    local start_port="${1:-8090}"
    local port="$start_port"

    while [ "$port" -le 65535 ]; do
        if ! is_port_in_use "$port"; then
            echo "$port"
            return 0
        fi

        port=$((port + 1))
    done

    return 1
}

prompt_available_port() {
    local default_port="8090"
    local suggested_port

    if is_port_in_use "$default_port"; then
        if suggested_port=$(find_available_port "$((default_port + 1))"); then
            warn "默认端口 ${default_port} 已被占用,建议使用 ${suggested_port}。"
            default_port="$suggested_port"
        else
            error "没有找到可用端口,请手动释放端口后重试。"
            return 1
        fi
    fi

    while true; do
        read -r -p "请输入此项目实例的端口号 (留空则默认 ${default_port}): " PB_PORT
        PB_PORT=${PB_PORT:-"$default_port"}

        if ! validate_port "$PB_PORT"; then
            error "端口号必须是 1-65535 之间的数字。"
            continue
        fi

        if is_port_in_use "$PB_PORT"; then
            error "端口 ${PB_PORT} 已被占用,请换一个端口。"
            continue
        fi

        break
    done
}

caddy_site_exists() {
    local domain_name="$1"

    sudo grep -Fq "${domain_name} {" "$CADDYFILE" 2>/dev/null
}

prompt_domain_name() {
    while true; do
        read -r -p "请输入您要绑定的域名 (例如: ${PROJECT_NAME}.example.com): " DOMAIN_NAME

        if ! validate_domain "$DOMAIN_NAME"; then
            error "域名格式不正确。"
            continue
        fi

        if caddy_site_exists "$DOMAIN_NAME"; then
            error "Caddyfile 中已存在域名配置: ${DOMAIN_NAME},请换一个域名或先删除旧配置。"
            continue
        fi

        break
    done
}

prompt_admin_credentials() {
    while true; do
        read -r -p "请输入新 PocketBase 实例的管理员邮箱: " ADMIN_EMAIL
        if ! validate_email "$ADMIN_EMAIL"; then
            error "管理员邮箱格式不正确,请重新输入。"
            continue
        fi

        read -r -s -p "请输入新 PocketBase 实例的管理员密码: " ADMIN_PASS
        echo # 换行
        if [ -z "$ADMIN_PASS" ]; then
            error "管理员密码不能为空,请重新输入。"
            continue
        fi

        break
    done
}

get_project_port() {
    local project_name="$1"
    local service_file_path="/etc/systemd/system/pocketbase-${project_name}.service"

    if sudo test -f "$service_file_path"; then
        sudo awk '/ExecStart=/ {
            if (match($0, /127[.]0[.]0[.]1:[0-9]+/)) {
                port = substr($0, RSTART, RLENGTH)
                sub(/^127[.]0[.]0[.]1:/, "", port)
                print port
                exit
            }
        }' "$service_file_path"
    fi
}

get_caddy_domains_by_port() {
    local port="$1"

    if [ -z "$port" ] || ! sudo test -f "$CADDYFILE"; then
        return 0
    fi

    sudo awk -v port="$port" '
        /^[[:space:]]*[^#[:space:]].*\{[[:space:]]*$/ {
            site = $0
            sub(/[[:space:]]*\{[[:space:]]*$/, "", site)
            gsub(/^[[:space:]]+|[[:space:]]+$/, "", site)
        }

        $0 ~ "reverse_proxy[[:space:]]+(127[.]0[.]0[.]1|localhost):" port "([[:space:]]|$)" {
            if (site != "" && site != "{") {
                print site
            }
        }
    ' "$CADDYFILE" | paste -sd ',' -
}

show_existing_projects() {
    local found=0
    local project_path
    local project_name
    local service_name
    local active_state
    local enabled_state
    local port
    local domains

    echo -e "\n${GREEN}--- 当前检测到的 PocketBase 项目实例 ---${NC}"

    if ! sudo test -d "$PB_PROJECTS_DIR"; then
        warn "实例目录 ${PB_PROJECTS_DIR} 不存在,暂无可展示的项目。"
        return 0
    fi

    printf '%-24s %-10s %-10s %-8s %s\n' "项目名" "运行状态" "开机自启" "端口" "Caddy 域名"
    printf '%-24s %-10s %-10s %-8s %s\n' "------------------------" "----------" "----------" "--------" "----------------"

    while IFS= read -r project_path; do
        found=1
        project_name=$(basename "$project_path")
        service_name="pocketbase-${project_name}"
        active_state=$(sudo systemctl is-active "$service_name" 2>/dev/null || true)
        enabled_state=$(sudo systemctl is-enabled "$service_name" 2>/dev/null || true)
        port=$(get_project_port "$project_name")
        domains=$(get_caddy_domains_by_port "$port")

        [ -n "$active_state" ] || active_state="unknown"
        [ -n "$enabled_state" ] || enabled_state="unknown"
        [ -n "$port" ] || port="-"
        [ -n "$domains" ] || domains="-"

        printf '%-24s %-10s %-10s %-8s %s\n' "$project_name" "$active_state" "$enabled_state" "$port" "$domains"
    done < <(sudo find "$PB_PROJECTS_DIR" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort)

    if [ "$found" -eq 0 ]; then
        warn "未在 ${PB_PROJECTS_DIR} 下发现项目目录。"
    fi
}

# ------------------------------------------------------------------------------
# 函数: 首次安装 PocketBase 和 Caddy
# ------------------------------------------------------------------------------
initial_setup() {
    info "--- 1. 开始系统更新与清理 ---"
    sudo apt update && sudo apt upgrade -y && sudo apt autoremove -y
    info "系统更新完成。"

    echo -e "\n${GREEN}--- 2. 安装必要依赖 ---${NC}"
    install_dependencies

    echo -e "\n${GREEN}--- 3. 下载 PocketBase ---${NC}"
    prompt_pocketbase_version_and_download

    echo -e "\n${GREEN}--- 4. 安装 Caddy ---${NC}"
    install_caddy

    echo -e "\n${GREEN}--- 5. 配置 PocketBase 专用用户 ---${NC}"
    ensure_pocketbase_user
    ensure_projects_dir

    echo -e "\n${GREEN}--- 6. 配置 Caddy ---${NC}"
    read -r -p "请输入用于 Caddy 自动申请 SSL 证书的邮箱: " CADDY_EMAIL
    if ! validate_email "$CADDY_EMAIL"; then
        error "邮箱格式不正确。"
        return 1
    fi
    ensure_caddy_email "$CADDY_EMAIL"

    echo -e "\n${GREEN}--- 7. 启动并设置 Caddy 开机自启 ---${NC}"
    sudo systemctl enable caddy
    sudo systemctl start caddy
    sudo systemctl status caddy --no-pager || true
    info "Caddy 已启动并设置开机自启。"

    echo -e "\n${GREEN}--- 8. 配置防火墙 (ufw) ---${NC}"
    warn "为 Caddy 开放 HTTP (80) 和 HTTPS (443) 端口..."
    sudo ufw allow 80/tcp
    sudo ufw allow 443/tcp
    info "防火墙规则已添加。注意:脚本不会自动启用 ufw,避免影响现有 SSH 连接。"

    echo -e "\n${GREEN}🎉 首次安装全部完成!${NC}"
    warn "再次执行脚本可以开始添加 PocketBase 项目。"
}

# ------------------------------------------------------------------------------
# 函数: 添加新的 PocketBase 项目实例
# ------------------------------------------------------------------------------
add_project() {
    local tmp_dir

    info "--- 1. 准备项目环境 ---"
    install_dependencies
    ensure_caddy_available
    ensure_pocketbase_user
    ensure_projects_dir

    read -r -p "请输入新项目的文件夹名称 (例如: project1): " PROJECT_NAME
    if ! validate_project_name "$PROJECT_NAME"; then
        error "项目名称只能包含字母、数字、下划线和短横线。"
        return 1
    fi

    WORKING_DIR="${PB_PROJECTS_DIR}/${PROJECT_NAME}"
    if sudo test -e "$WORKING_DIR"; then
        error "实例目录 ${WORKING_DIR} 已存在。"
        return 1
    fi

    SERVICE_NAME="pocketbase-$PROJECT_NAME"
    SERVICE_FILE_PATH="/etc/systemd/system/${SERVICE_NAME}.service"

    if sudo test -f "$SERVICE_FILE_PATH"; then
        error "服务文件已存在: ${SERVICE_FILE_PATH}"
        return 1
    fi

    echo -e "\n${GREEN}--- 2. 检查实例端口 ---${NC}"
    prompt_available_port
    warn "PocketBase 将仅监听 127.0.0.1:${PB_PORT},不会开放项目端口到公网。"

    echo -e "\n${GREEN}--- 3. 检查 Caddy 域名 ---${NC}"
    prompt_domain_name

    echo -e "\n${GREEN}--- 4. 检查管理员账号 ---${NC}"
    prompt_admin_credentials

    prompt_pocketbase_version_and_download

    warn "正在创建目录 ${WORKING_DIR} 并解压 PocketBase..."
    tmp_dir=$(mktemp -d)
    unzip -q "$PB_ZIP_PATH" -d "$tmp_dir"
    sudo install -d -o "$PB_SYSTEM_USER" -g "$PB_SYSTEM_GROUP" -m 0750 "$WORKING_DIR"
    sudo cp -a "${tmp_dir}/." "$WORKING_DIR/"
    rm -rf "$tmp_dir"
    sudo chmod +x "${WORKING_DIR}/pocketbase"
    sudo chown -R "${PB_SYSTEM_USER}:${PB_SYSTEM_GROUP}" "$WORKING_DIR"
    info "项目准备完成,工作目录: ${WORKING_DIR}"

    echo -e "\n${GREEN}--- 5. 配置 Caddy 反向代理 ---${NC}"
    append_caddy_site "$DOMAIN_NAME" "$PB_PORT"

    echo -e "\n${GREEN}--- 6. 创建 systemd 服务 ---${NC}"
    warn "正在创建服务文件: ${SERVICE_FILE_PATH}"
    sudo tee "$SERVICE_FILE_PATH" > /dev/null <<EOF
[Unit]
Description=PocketBase ${PROJECT_NAME} service
After=network.target

[Service]
User=${PB_SYSTEM_USER}
Group=${PB_SYSTEM_GROUP}
Type=simple
WorkingDirectory=${WORKING_DIR}
ExecStart=${WORKING_DIR}/pocketbase serve --http="127.0.0.1:${PB_PORT}"
Restart=always
RestartSec=3
LimitNOFILE=4096

[Install]
WantedBy=multi-user.target
EOF
    info "服务文件创建成功。"

    echo -e "\n${GREEN}--- 7. 启动服务并创建 PocketBase 管理员 ---${NC}"
    sudo systemctl daemon-reload
    sudo systemctl enable "$SERVICE_NAME"
    sudo systemctl start "$SERVICE_NAME"

    warn "已启动服务,等待 3 秒让 PocketBase 初始化数据库..."
    sleep 3

    # 停止服务以安全地创建管理员 (防止 "database is locked")
    sudo systemctl stop "$SERVICE_NAME"
    warn "服务已临时停止,正在创建超级管理员..."

    sudo -u "$PB_SYSTEM_USER" bash -c 'cd "$1" && ./pocketbase superuser upsert "$2" "$3"' _ "$WORKING_DIR" "$ADMIN_EMAIL" "$ADMIN_PASS"

    warn "管理员创建成功,正在重启服务..."
    sudo systemctl start "$SERVICE_NAME"

    echo -e "\n${GREEN}服务状态:${NC}"
    sudo systemctl status "$SERVICE_NAME" --no-pager || true

    echo -e "\n${GREEN}--- 🎉 新项目添加完成! ---${NC}"
    echo -e "项目名称: ${YELLOW}${PROJECT_NAME}${NC}"
    echo -e "PocketBase 版本: ${YELLOW}${PB_VERSION}${NC}"
    echo -e "后台地址: ${YELLOW}https://${DOMAIN_NAME}/_${NC}"
    echo -e "管理员邮箱: ${YELLOW}${ADMIN_EMAIL}${NC}"
}

# ------------------------------------------------------------------------------
# 函数: 删除 PocketBase 项目实例
# ------------------------------------------------------------------------------
delete_project() {
    local project_name
    local working_dir
    local service_name
    local service_file_path
    local domain_name
    local detected_port
    local detected_domains
    local confirm

    info "--- 删除 PocketBase 项目实例 ---"
    show_existing_projects
    echo

    read -r -p "请输入要删除的项目名称: " project_name
    if ! validate_project_name "$project_name"; then
        error "项目名称只能包含字母、数字、下划线和短横线。"
        return 1
    fi

    working_dir="${PB_PROJECTS_DIR}/${project_name}"
    service_name="pocketbase-${project_name}"
    service_file_path="/etc/systemd/system/${service_name}.service"
    detected_port=$(get_project_port "$project_name")
    detected_domains=$(get_caddy_domains_by_port "$detected_port")

    echo -e "将删除以下资源:"
    echo -e "  systemd 服务: ${YELLOW}${service_name}${NC}"
    echo -e "  服务文件: ${YELLOW}${service_file_path}${NC}"
    echo -e "  实例目录: ${YELLOW}${working_dir}${NC}"
    echo -e "  检测端口: ${YELLOW}${detected_port:-未检测到}${NC}"
    echo -e "  检测域名: ${YELLOW}${detected_domains:-未检测到}${NC}"
    echo
    if [ -n "$detected_domains" ] && [[ "$detected_domains" != *,* ]]; then
        read -r -p "请输入要删除的 Caddy 域名 (回车默认 ${detected_domains},输入 - 跳过): " domain_name
        domain_name=${domain_name:-"$detected_domains"}
    else
        read -r -p "请输入该实例绑定的域名,用于删除 Caddy 配置 (留空则跳过 Caddy 配置删除): " domain_name
    fi
    if [ "$domain_name" = "-" ]; then
        domain_name=""
    fi
    if [ -n "$domain_name" ] && ! validate_domain "$domain_name"; then
        error "域名格式不正确。"
        return 1
    fi

    echo
    warn "此操作不可逆,请确认没有需要保留的 PocketBase 数据。"
    read -r -p "确认删除项目 ${project_name}? 请输入 DELETE 确认: " confirm
    if [ "$confirm" != "DELETE" ]; then
        warn "未确认删除,操作已取消。"
        return 0
    fi

    warn "正在停止并禁用 systemd 服务..."
    sudo systemctl disable --now "$service_name" 2>/dev/null || true

    if sudo test -f "$service_file_path"; then
        warn "正在删除服务文件: ${service_file_path}"
        sudo rm -f "$service_file_path"
    else
        warn "未找到服务文件: ${service_file_path}"
    fi

    sudo systemctl daemon-reload
    sudo systemctl reset-failed "$service_name" 2>/dev/null || true

    if sudo test -e "$working_dir"; then
        warn "正在删除实例目录: ${working_dir}"
        sudo rm -rf "$working_dir"
    else
        warn "未找到实例目录: ${working_dir}"
    fi

    if [ -n "$domain_name" ]; then
        ensure_caddy_available
        remove_caddy_site "$domain_name"
    fi

    echo -e "\n${GREEN}--- 删除完成 ---${NC}"
    echo -e "项目名称: ${YELLOW}${project_name}${NC}"
    if [ -n "$domain_name" ]; then
        echo -e "已处理 Caddy 域名配置: ${YELLOW}${domain_name}${NC}"
    else
        echo -e "Caddy 配置: ${YELLOW}已跳过${NC}"
    fi
}

# ------------------------------------------------------------------------------
# 主菜单
# ------------------------------------------------------------------------------
main_menu() {
    while true; do
        echo -e "\n${GREEN}=====================================${NC}"
        echo -e "${GREEN}  PocketBase & Caddy 多项目管理脚本  ${NC}"
        echo -e "${GREEN}=====================================${NC}"
        echo -e "请选择一个操作:"
        echo -e "  ${YELLOW}1)${NC} 首次安装 PocketBase 和 Caddy"
        echo -e "  ${YELLOW}2)${NC} 添加一个新的 PocketBase 项目实例"
        echo -e "  ${YELLOW}3)${NC} 删除一个 PocketBase 项目实例"
        echo -e "  ${YELLOW}4)${NC} 退出脚本"
        read -r -p "请输入选项 [1-4]: " choice

        case "$choice" in
            1)
                initial_setup
                ;;
            2)
                add_project
                ;;
            3)
                delete_project
                ;;
            4)
                info "再见!"
                exit 0
                ;;
            *)
                error "无效选项,请输入 1, 2, 3 或 4。"
                ;;
        esac
    done
}

# 运行主菜单
main_menu
# 后续步骤:
# 授予执行权限:chmod +x pocketbase.sh
# 运行脚本:./pocketbase.sh

授予执行权限

chmod +x pocketbase.sh

运行脚本

./pocketbase.sh