#!/usr/bin/env bash set -euo pipefail # Ubuntu 一键部署/更新脚本。 # - 首次执行会交互式生成 .env.deploy,本机保存,不提交 git。 # - 后续执行会拉取代码、更新依赖、构建前端、安装/重启 systemd 服务。 ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ENV_FILE="${ROOT_DIR}/.env.deploy" PYTHON_VERSION="${PYTHON_VERSION:-3.10.14}" DEPLOY_INSTANCE="${CLAW_DEPLOY_INSTANCE:-zk-data-agent}" BACKEND_SERVICE="${CLAW_BACKEND_SERVICE:-${DEPLOY_INSTANCE}-backend}" FRONTEND_SERVICE="${CLAW_FRONTEND_SERVICE:-${DEPLOY_INSTANCE}-frontend}" USER_SYSTEMD_DIR="${HOME}/.config/systemd/user" SYSTEM_SYSTEMD_DIR="/etc/systemd/system" BRANCH="" FORCE=0 SKIP_GIT=0 BOOTSTRAP_SYSTEM=0 SERVICE_SCOPE="${CLAW_SERVICE_SCOPE:-}" ENABLE_LINUX_ACCOUNTS="${CLAW_ENABLE_LINUX_ACCOUNTS:-}" usage() { cat < 覆盖本地改动 --skip-git 跳过 git 拉取,只部署当前工作区 --bootstrap-system 强制使用 sudo 安装 Ubuntu 系统依赖;首次安装会默认执行 --system-service 安装 root/system 级 systemd 服务;以 root 执行时默认启用 --user-service 安装当前用户级 systemd 服务 --enable-linux-accounts 启用平台账号同名 Linux 用户工作区;要求 system 服务和 root 权限 EOF } while [[ $# -gt 0 ]]; do case "$1" in --force) FORCE=1 shift ;; --skip-git) SKIP_GIT=1 shift ;; --bootstrap-system) BOOTSTRAP_SYSTEM=1 shift ;; --system-service) SERVICE_SCOPE="system" shift ;; --user-service) SERVICE_SCOPE="user" shift ;; --enable-linux-accounts) ENABLE_LINUX_ACCOUNTS="1" shift ;; -h|--help) usage exit 0 ;; *) if [[ -n "${BRANCH}" ]]; then echo "Unexpected argument: $1" >&2 usage >&2 exit 1 fi BRANCH="$1" shift ;; esac done REQUESTED_SERVICE_SCOPE="${SERVICE_SCOPE}" REQUESTED_ENABLE_LINUX_ACCOUNTS="${ENABLE_LINUX_ACCOUNTS}" log() { printf "\n\033[1;34m==>\033[0m %s\n" "$1" } warn() { printf "\033[1;33mWARN\033[0m %s\n" "$1" >&2 } fail() { printf "\033[1;31mERROR\033[0m %s\n" "$1" >&2 exit 1 } configure_instance_names() { if [[ -z "${SERVICE_SCOPE}" && -n "${CLAW_SERVICE_SCOPE:-}" ]]; then SERVICE_SCOPE="${CLAW_SERVICE_SCOPE}" fi DEPLOY_INSTANCE="${CLAW_DEPLOY_INSTANCE:-${DEPLOY_INSTANCE:-zk-data-agent}}" BACKEND_SERVICE="${CLAW_BACKEND_SERVICE:-${DEPLOY_INSTANCE}-backend}" FRONTEND_SERVICE="${CLAW_FRONTEND_SERVICE:-${DEPLOY_INSTANCE}-frontend}" if [[ -z "${SERVICE_SCOPE}" ]]; then if [[ "${EUID}" == "0" ]]; then SERVICE_SCOPE="system" else SERVICE_SCOPE="user" fi fi if [[ -z "${ENABLE_LINUX_ACCOUNTS}" ]]; then ENABLE_LINUX_ACCOUNTS="${CLAW_ENABLE_LINUX_ACCOUNTS:-0}" fi } require_command() { local name="$1" local hint="$2" if ! command -v "${name}" >/dev/null 2>&1; then fail "缺少命令:${name}。${hint}" fi } resolve_npm_bin() { if [[ -n "${CLAW_NODE_BIN_DIR:-}" ]]; then export PATH="${CLAW_NODE_BIN_DIR}:${PATH}" fi local npm_bin="${CLAW_NPM_BIN:-$(command -v npm || true)}" if [[ -z "${npm_bin}" || ! -x "${npm_bin}" ]]; then fail "缺少命令:npm。请先安装 Node.js 和 npm,建议 Node.js 20 或 22。" fi local node_bin_dir node_bin_dir="$(dirname "${npm_bin}")" export PATH="${node_bin_dir}:${PATH}" if ! "${npm_bin}" --version >/dev/null 2>&1; then fail "npm 不可用:${npm_bin}。请确认同目录下存在 node,或重新安装 Node.js/npm。" fi printf "%s\n" "${npm_bin}" } bootstrap_system_packages() { if [[ "${BOOTSTRAP_SYSTEM}" != "1" && -f "${ENV_FILE}" ]]; then return fi require_command sudo "首次安装系统依赖需要 sudo。" if ! command -v apt-get >/dev/null 2>&1; then warn "未发现 apt-get,跳过系统依赖安装。请手工安装 git、curl、pyenv、Node.js、npm 和 Python 编译依赖。" return fi log "首次初始化 Ubuntu 系统依赖" sudo apt-get update sudo apt-get install -y \ git curl ca-certificates build-essential \ libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev \ libffi-dev liblzma-dev libncursesw5-dev xz-utils tk-dev \ systemd if [[ "${ENABLE_LINUX_ACCOUNTS}" == "1" ]]; then sudo apt-get install -y passwd python3 python3-venv fi } configure_nginx_upload_limit() { if [[ "${BOOTSTRAP_SYSTEM}" != "1" && -f "${ENV_FILE}" ]]; then return fi local script="${ROOT_DIR}/scripts/configure-nginx-upload-limit.sh" if [[ -x "${script}" ]]; then log "配置 nginx 上传体积限制" "${script}" || warn "nginx 上传体积限制配置失败,可稍后手动执行:bash ${script}" fi } prompt_value() { local var_name="$1" local prompt="$2" local default_value="${3:-}" local secret="${4:-0}" local value if [[ "${secret}" == "1" ]]; then read -r -s -p "${prompt}" value printf "\n" else if [[ -n "${default_value}" ]]; then read -r -p "${prompt} [${default_value}]: " value else read -r -p "${prompt}: " value fi fi value="${value:-${default_value}}" printf -v "${var_name}" "%s" "${value}" } write_env_file() { local api_key="$1" local base_url="$2" local model="$3" local backend_host="$4" local backend_port="$5" local frontend_host="$6" local frontend_port="$7" local claw_api_url="http://${backend_host}:${backend_port}" umask 077 cat >"${ENV_FILE}" <"${tmp_file}" || true printf 'export %s=%s\n' "${key}" "$(printf "%q" "${value}")" >>"${tmp_file}" install -m 0600 "${tmp_file}" "${ENV_FILE}" rm -f "${tmp_file}" } ensure_env_file() { if [[ -f "${ENV_FILE}" ]]; then # shellcheck disable=SC1090 source "${ENV_FILE}" if [[ -n "${REQUESTED_SERVICE_SCOPE}" ]]; then SERVICE_SCOPE="${REQUESTED_SERVICE_SCOPE}" export CLAW_SERVICE_SCOPE="${REQUESTED_SERVICE_SCOPE}" fi if [[ -n "${REQUESTED_ENABLE_LINUX_ACCOUNTS}" ]]; then ENABLE_LINUX_ACCOUNTS="${REQUESTED_ENABLE_LINUX_ACCOUNTS}" export CLAW_ENABLE_LINUX_ACCOUNTS="${REQUESTED_ENABLE_LINUX_ACCOUNTS}" fi configure_instance_names if [[ -n "${REQUESTED_SERVICE_SCOPE}" ]]; then upsert_env_export CLAW_SERVICE_SCOPE "${SERVICE_SCOPE}" fi if [[ -n "${REQUESTED_ENABLE_LINUX_ACCOUNTS}" ]]; then upsert_env_export CLAW_ENABLE_LINUX_ACCOUNTS "${ENABLE_LINUX_ACCOUNTS}" fi if [[ -z "${OPENAI_API_KEY:-}" ]]; then fail "${ENV_FILE} 缺少 OPENAI_API_KEY,请编辑该文件补齐。" fi log "使用本机部署配置:${ENV_FILE}" echo "CLAW_DEPLOY_INSTANCE=${DEPLOY_INSTANCE}" echo "OPENAI_BASE_URL=${OPENAI_BASE_URL:-}" echo "OPENAI_MODEL=${OPENAI_MODEL:-}" echo "OPENAI_TIMEOUT_SECONDS=${OPENAI_TIMEOUT_SECONDS:-3600}" echo "CLAW_MODEL_IDLE_TIMEOUT_SECONDS=${CLAW_MODEL_IDLE_TIMEOUT_SECONDS:-60}" echo "CLAW_SERVICE_SCOPE=${SERVICE_SCOPE}" echo "CLAW_ENABLE_LINUX_ACCOUNTS=${CLAW_ENABLE_LINUX_ACCOUNTS:-0}" echo "OPENAI_API_KEY=已配置" return fi if [[ ! -t 0 ]]; then fail "首次部署需要交互式输入 OPENAI_API_KEY。请登录机器后执行:bash scripts/deploy-ubuntu.sh" fi log "首次部署:生成本机配置 ${ENV_FILE}" echo "配置会写入 ${ENV_FILE},该文件已被 .gitignore 忽略。" local api_key base_url model backend_host backend_port frontend_host frontend_port prompt_value api_key "请输入 OPENAI_API_KEY: " "" 1 [[ -n "${api_key}" ]] || fail "OPENAI_API_KEY 不能为空。" prompt_value base_url "请输入 OPENAI_BASE_URL" "http://model.mify.ai.srv/v1" prompt_value model "请输入 OPENAI_MODEL" "tongyi/deepseek-v4-pro" configure_instance_names if [[ "${ENABLE_LINUX_ACCOUNTS}" == "1" && "${SERVICE_SCOPE}" != "system" ]]; then fail "--enable-linux-accounts 需要 --system-service,并建议使用 sudo/root 执行部署。" fi prompt_value backend_host "请输入后端监听地址" "127.0.0.1" prompt_value backend_port "请输入后端端口" "8765" prompt_value frontend_host "请输入前端监听地址" "0.0.0.0" prompt_value frontend_port "请输入前端端口" "3000" write_env_file "${api_key}" "${base_url}" "${model}" "${backend_host}" "${backend_port}" "${frontend_host}" "${frontend_port}" echo "已写入 ${ENV_FILE},后续修改配置请编辑这个文件。" } update_git() { if [[ "${SKIP_GIT}" == "1" ]]; then log "跳过 git 拉取,使用当前工作区" return fi require_command git "请先安装 git。" cd "${ROOT_DIR}" [[ -d .git ]] || fail "${ROOT_DIR} 不是 git 仓库。" if [[ -z "${BRANCH}" ]]; then BRANCH="$(git branch --show-current)" fi [[ -n "${BRANCH}" ]] || fail "无法判断当前分支,请显式传入分支名。" log "更新代码:origin/${BRANCH}" git fetch origin git checkout "${BRANCH}" if [[ "${FORCE}" == "1" ]]; then git reset --hard "origin/${BRANCH}" else if [[ -n "$(git status --porcelain)" ]]; then fail "工作区有未提交改动。请先处理,或使用 --force 覆盖。" fi git pull --ff-only origin "${BRANCH}" fi } ensure_python() { require_command pyenv "请先安装 pyenv,并安装 Python ${PYTHON_VERSION}。" log "准备 Python 环境:${PYTHON_VERSION}" cd "${ROOT_DIR}" if ! pyenv versions --bare | grep -Fx "${PYTHON_VERSION}" >/dev/null 2>&1; then echo "pyenv 未发现 Python ${PYTHON_VERSION},开始安装。" pyenv install "${PYTHON_VERSION}" fi PYENV_VERSION="${PYTHON_VERSION}" pyenv exec python -m venv "${ROOT_DIR}/.venv" "${ROOT_DIR}/.venv/bin/python" -m pip install --upgrade pip setuptools wheel "${ROOT_DIR}/.venv/bin/python" -m pip install -e . } build_frontend() { local npm_bin npm_bin="$(resolve_npm_bin)" export PATH="$(dirname "${npm_bin}"):${PATH}" log "安装并构建前端" cd "${ROOT_DIR}/frontend/app" if [[ -f package-lock.json ]]; then "${npm_bin}" ci else "${npm_bin}" install fi "${npm_bin}" run build } remember_node_runtime() { local npm_bin npx_bin node_bin node_bin_dir tmp_file npm_bin="$(resolve_npm_bin)" node_bin_dir="$(dirname "${npm_bin}")" export PATH="${node_bin_dir}:${PATH}" node_bin="$(PATH="${node_bin_dir}:${PATH}" command -v node || true)" npx_bin="$(PATH="${node_bin_dir}:${PATH}" command -v npx || true)" tmp_file="$(mktemp)" grep -v -E '^(# systemd 用户服务不会读取交互式 shell 配置|export CLAW_(NPM_BIN|NPX_BIN|NODE_BIN|NODE_BIN_DIR)=)' "${ENV_FILE}" >"${tmp_file}" || true cat >>"${tmp_file}" <"${tmp_path}" install -m 0644 "${tmp_path}" "${target_path}" rm -f "${tmp_path}" } install_services() { require_command systemctl "当前系统不支持 systemd,无法安装服务。" if [[ "${ENABLE_LINUX_ACCOUNTS}" == "1" && "${SERVICE_SCOPE}" != "system" ]]; then fail "CLAW_ENABLE_LINUX_ACCOUNTS=1 需要 system 服务。请用 sudo bash scripts/deploy-ubuntu.sh --system-service --enable-linux-accounts。" fi log "安装/更新 ${SERVICE_SCOPE} systemd 服务" install_systemd_service "${BACKEND_SERVICE}" "${ROOT_DIR}/deploy/systemd/zk-data-agent-backend.service.template" install_systemd_service "${FRONTEND_SERVICE}" "${ROOT_DIR}/deploy/systemd/zk-data-agent-frontend.service.template" if [[ "${SERVICE_SCOPE}" == "system" ]]; then systemctl daemon-reload systemctl enable "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}" else systemctl --user daemon-reload systemctl --user enable "${BACKEND_SERVICE}" "${FRONTEND_SERVICE}" fi } restart_services() { log "重启服务" if [[ "${SERVICE_SCOPE}" == "system" ]]; then systemctl restart "${BACKEND_SERVICE}" systemctl restart "${FRONTEND_SERVICE}" else systemctl --user restart "${BACKEND_SERVICE}" systemctl --user restart "${FRONTEND_SERVICE}" fi } health_check() { # shellcheck disable=SC1090 source "${ENV_FILE}" local backend_url="http://${CLAW_BACKEND_HOST:-127.0.0.1}:${CLAW_BACKEND_PORT:-8765}/api/state" local frontend_url="http://127.0.0.1:${CLAW_FRONTEND_PORT:-3000}/" log "健康检查" for _ in {1..40}; do if curl -fsS "${backend_url}" >/dev/null 2>&1 && curl -fsS "${frontend_url}" >/dev/null 2>&1; then echo "backend: ${backend_url}" echo "frontend: http://<机器IP>:${CLAW_FRONTEND_PORT:-3000}/" echo "服务已启动。" return fi sleep 0.5 done warn "健康检查失败,输出最近日志。" if [[ "${SERVICE_SCOPE}" == "system" ]]; then journalctl -u "${BACKEND_SERVICE}" -n 80 --no-pager || true journalctl -u "${FRONTEND_SERVICE}" -n 80 --no-pager || true else journalctl --user -u "${BACKEND_SERVICE}" -n 80 --no-pager || true journalctl --user -u "${FRONTEND_SERVICE}" -n 80 --no-pager || true fi exit 1 } main() { log "ZK Data Agent Ubuntu 部署" bootstrap_system_packages configure_nginx_upload_limit update_git ensure_env_file configure_instance_names ensure_python build_frontend remember_node_runtime install_services restart_services health_check } main "$@"