#!/usr/bin/env bash set -Eeuo pipefail APP_NAME="${CC_WEB_PM2_NAME:-ccweb}" ENTRY_FILE="${CC_WEB_ENTRY:-server.js}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" APP_DIR="${SCRIPT_DIR}" ENTRY_PATH="${APP_DIR}/${ENTRY_FILE}" ENV_FILE="${APP_DIR}/.env" ENV_EXAMPLE_FILE="${APP_DIR}/.env.example" START_MODE="${CC_WEB_START_MODE:-auto}" RELEASE_TARGET="bun-linux-x64-baseline" RELEASE_DIR="${CC_WEB_RELEASE_DIR:-${APP_DIR}/dist-exe/${RELEASE_TARGET}}" RELEASE_ENTRY_PATH="${RELEASE_DIR}/cc-web" RELEASE_ARCHIVE="${CC_WEB_RELEASE_ARCHIVE:-${APP_DIR}/dist-exe/cc-web-${RELEASE_TARGET}.tar.gz}" PORT_VALUE="" log() { printf '[cc-web pm2] %s\n' "$*" } fail() { printf '[cc-web pm2] ERROR: %s\n' "$*" >&2 exit 1 } warn() { printf '[cc-web pm2] WARNING: %s\n' "$*" >&2 } is_centos7() { local release_file for release_file in /etc/centos-release /etc/redhat-release; do if [[ -r "${release_file}" ]] && grep -Eqi 'centos.*release[[:space:]]+7' "${release_file}"; then return 0 fi done return 1 } resolve_port() { local configured="${PORT:-$(get_env_value PORT)}" if [[ ! "${configured}" =~ ^[0-9]+$ ]] || ((configured < 1 || configured > 65535)); then configured=8002 fi printf '%s\n' "${configured}" } port_is_open() { local port="$1" ( : >/dev/tcp/127.0.0.1/"${port}" ) 2>/dev/null } list_port_pids() { local port="$1" if command -v fuser >/dev/null 2>&1; then fuser -n tcp "${port}" 2>/dev/null || true return 0 fi if command -v lsof >/dev/null 2>&1; then lsof -t -iTCP:"${port}" -sTCP:LISTEN 2>/dev/null || true return 0 fi if command -v ss >/dev/null 2>&1; then ss -ltnp 2>/dev/null \ | awk -v port=":${port}" 'NR > 1 && $4 ~ (port "$") { print }' \ | sed -n 's/.*pid=\([0-9][0-9]*\).*/\1/p' \ | sort -u return 0 fi if command -v netstat >/dev/null 2>&1; then netstat -ltnp 2>/dev/null \ | awk -v port=":${port}" 'NR > 2 && $4 ~ (port "$") { print $7 }' \ | sed -n 's#^\([0-9][0-9]*\)/.*#\1#p' \ | sort -u return 0 fi return 1 } wait_for_port() { local port="$1" local attempts="${2:-20}" local index for ((index = 0; index < attempts; index += 1)); do if port_is_open "${port}"; then return 0 fi sleep 0.5 done return 1 } stop_port_listener() { local port="$1" local pids="" local kill_mode="" if ! port_is_open "${port}"; then log "端口 ${port} 当前未被占用" return 0 fi if command -v fuser >/dev/null 2>&1; then kill_mode="fuser" elif command -v lsof >/dev/null 2>&1; then kill_mode="kill" elif command -v ss >/dev/null 2>&1 || command -v netstat >/dev/null 2>&1; then kill_mode="kill" else warn "端口 ${port} 被占用,但系统没有 fuser/lsof/ss/netstat,无法自动关闭" return 1 fi pids="$(list_port_pids "${port}")" if [[ -z "${pids//[[:space:]]/}" ]]; then warn "端口 ${port} 被占用,但无法获取占用进程 PID,拒绝继续启动以免误报" return 1 fi log "关闭端口 ${port},占用进程: ${pids}" if [[ "${kill_mode}" == "fuser" ]]; then fuser -k -TERM -n tcp "${port}" >/dev/null 2>&1 || true else for pid in ${pids}; do kill -TERM "${pid}" 2>/dev/null || true done fi for _ in {1..10}; do if ! port_is_open "${port}"; then return 0 fi sleep 0.3 done warn "端口 ${port} 未能在宽限期内释放,尝试强制关闭" if [[ "${kill_mode}" == "fuser" ]]; then fuser -k -KILL -n tcp "${port}" >/dev/null 2>&1 || true else for pid in ${pids}; do kill -KILL "${pid}" 2>/dev/null || true done fi return 0 } node_upgrade_hint() { cat <<'EOF' 可选处理方式: # 已安装 nvm 时,使用当前 LTS 版本 nvm install --lts nvm use --lts node -v # Ubuntu / Debian / WSL,可固定安装 Node.js 22;root 用户可去掉 sudo curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - sudo apt-get install -y nodejs node -v # RHEL / Rocky / AlmaLinux 8/9,可固定安装 Node.js 22;root 用户可去掉 sudo curl -fsSL https://rpm.nodesource.com/setup_22.x | sudo bash - sudo dnf install -y nodejs || sudo yum install -y nodejs node -v # CentOS 7 / glibc 2.17 不适合安装 NodeSource Node.js 22。 # 推荐在较新的构建机生成 Bun baseline 单文件发布包,再拷贝到 CentOS 7: npm install npm run build:single-exe # 在项目根目录执行 ./start.sh 时,会自动优先尝试源码;源码失败后自动回退到: # dist-exe/bun-linux-x64-baseline/cc-web EOF } ensure_command() { local cmd="$1" local hint="$2" if ! command -v "${cmd}" >/dev/null 2>&1; then fail "${cmd} 未安装。${hint}" fi } get_env_value() { local key="$1" [[ -f "${ENV_FILE}" ]] || return 0 awk -v key="${key}" ' $0 ~ /^[[:space:]]*#/ { next } { line = $0 sub(/^[[:space:]]*/, "", line) if (index(line, key "=") == 1) { sub(/^[^=]*=/, "", line) print line exit } } ' "${ENV_FILE}" } set_env_value() { local key="$1" local value="$2" local tmp_file tmp_file="$(mktemp)" if [[ -f "${ENV_FILE}" ]]; then awk -v key="${key}" -v value="${value}" ' BEGIN { updated = 0 } { line = $0 sub(/^[[:space:]]*/, "", line) if (index(line, key "=") == 1) { print key "=" value updated = 1 next } print } END { if (!updated) print key "=" value } ' "${ENV_FILE}" > "${tmp_file}" else printf '%s=%s\n' "${key}" "${value}" > "${tmp_file}" fi mv "${tmp_file}" "${ENV_FILE}" chmod 600 "${ENV_FILE}" } validate_password() { local password="$1" local types=0 if [[ "${#password}" -lt 8 ]]; then printf '密码长度至少 8 位\n' return 1 fi [[ "${password}" =~ [a-z] ]] && types=$((types + 1)) [[ "${password}" =~ [A-Z] ]] && types=$((types + 1)) [[ "${password}" =~ [0-9] ]] && types=$((types + 1)) [[ "${password}" =~ [^a-zA-Z0-9] ]] && types=$((types + 1)) if [[ "${types}" -lt 2 ]]; then printf '密码需包含至少 2 种字符类型(大写/小写/数字/特殊字符)\n' return 1 fi return 0 } resolve_config_dir() { local configured configured="${CC_WEB_CONFIG_DIR:-$(get_env_value CC_WEB_CONFIG_DIR)}" if [[ -n "${configured}" ]]; then if [[ "${configured}" = /* ]]; then printf '%s\n' "${configured}" else printf '%s/%s\n' "${APP_DIR}" "${configured}" fi return fi printf '%s/config\n' "${APP_DIR}" } ensure_env_file() { if [[ -f "${ENV_FILE}" ]]; then log "检测到 .env 配置文件" chmod 600 "${ENV_FILE}" return fi if [[ -f "${ENV_EXAMPLE_FILE}" ]]; then cp "${ENV_EXAMPLE_FILE}" "${ENV_FILE}" chmod 600 "${ENV_FILE}" log "已从 .env.example 创建 .env" else : > "${ENV_FILE}" chmod 600 "${ENV_FILE}" log "已创建空 .env" fi } ensure_port_config() { local port port="$(get_env_value PORT)" if [[ -z "${port}" ]]; then set_env_value PORT "8002" log "未配置 PORT,已写入默认端口 8002" return fi log "服务端口: ${port}" } ensure_initial_password() { local config_dir auth_file env_password password confirm message config_dir="$(resolve_config_dir)" auth_file="${config_dir}/auth.json" if [[ -f "${auth_file}" ]]; then log "检测到已有登录配置: ${auth_file},跳过初始密码设置" return fi env_password="$(get_env_value CC_WEB_PASSWORD)" if [[ -n "${env_password}" && "${env_password}" != "changeme" ]]; then log "检测到 .env 已配置 CC_WEB_PASSWORD,首次启动时会迁移到 auth.json" return fi if [[ ! -t 0 ]]; then fail "首次启动缺少初始密码。请在 .env 中设置 CC_WEB_PASSWORD,或在交互终端执行 ./start.sh。" fi log "首次启动需要设置 Web 登录初始密码" while true; do read -r -s -p "请输入初始密码: " password printf '\n' read -r -s -p "请再次输入初始密码: " confirm printf '\n' if [[ "${password}" != "${confirm}" ]]; then log "两次输入不一致,请重新输入" continue fi if ! message="$(validate_password "${password}")"; then log "${message}" continue fi set_env_value CC_WEB_PASSWORD "${password}" log "初始密码已写入 .env;服务首次启动后会迁移到 config/auth.json" break done } check_agent_cli() { local key="$1" local default_cmd="$2" local label="$3" local configured configured="$(get_env_value "${key}")" configured="${configured:-${default_cmd}}" if command -v "${configured}" >/dev/null 2>&1; then log "${label} CLI: $(command -v "${configured}")" return fi log "未检测到 ${label} CLI (${configured});服务可启动,但对应 Agent 功能需要安装并登录后才能使用" } check_runtime_config() { ensure_env_file ensure_port_config ensure_initial_password check_agent_cli CLAUDE_PATH claude "Claude" check_agent_cli CODEX_PATH codex "Codex" } install_pm2() { if command -v pm2 >/dev/null 2>&1; then log "PM2 已安装: $(command -v pm2)" return fi log "未检测到 PM2,开始安装..." if npm install -g pm2; then log "PM2 安装完成" return fi if command -v sudo >/dev/null 2>&1; then log "普通 npm 全局安装失败,尝试使用 sudo 安装 PM2..." if sudo npm install -g pm2; then log "PM2 安装完成" return fi fi warn "PM2 自动安装失败" return 1 } install_dependencies() { cd "${APP_DIR}" if [[ -f package-lock.json ]]; then log "安装项目依赖: npm ci" if npm ci; then return 0 fi else log "安装项目依赖: npm install" if npm install; then return 0 fi fi warn "项目依赖安装失败" return 1 } start_or_restart_app() { cd "${APP_DIR}" if pm2 describe "${APP_NAME}" >/dev/null 2>&1; then log "PM2 应用已存在,执行重启: ${APP_NAME}" if ! pm2 restart "${APP_NAME}" --update-env; then warn "PM2 重启失败: ${APP_NAME}" return 1 fi else log "启动 PM2 应用: ${APP_NAME}" if ! pm2 start "${ENTRY_PATH}" --name "${APP_NAME}" --cwd "${APP_DIR}"; then warn "PM2 启动失败: ${APP_NAME}" return 1 fi fi log "保存 PM2 进程快照" if ! pm2 save; then warn "PM2 进程快照保存失败" return 1 fi log "当前 PM2 状态" if ! pm2 status "${APP_NAME}"; then warn "PM2 状态检查失败: ${APP_NAME}" return 1 fi if ! wait_for_port "${PORT_VALUE}" 20; then warn "源码服务未在端口 ${PORT_VALUE} 上监听" return 1 fi } stop_existing_pm2_app() { if command -v pm2 >/dev/null 2>&1 && pm2 describe "${APP_NAME}" >/dev/null 2>&1; then log "停止失败的 PM2 应用: ${APP_NAME}" pm2 delete "${APP_NAME}" >/dev/null 2>&1 || true fi } refresh_release_from_archive() { [[ -f "${RELEASE_ARCHIVE}" ]] || return 0 mkdir -p "${APP_DIR}/dist-exe" if [[ -x "${RELEASE_ENTRY_PATH}" ]] && command -v sha256sum >/dev/null 2>&1; then local current_hash archive_hash current_hash="$(sha256sum "${RELEASE_ENTRY_PATH}" | awk '{print $1}')" archive_hash="$(tar -xOf "${RELEASE_ARCHIVE}" "${RELEASE_TARGET}/cc-web" 2>/dev/null | sha256sum | awk '{print $1}')" if [[ -n "${archive_hash}" && "${current_hash}" == "${archive_hash}" ]]; then return 0 fi log "检测到 single-exe 发布目录不是归档中的最新版本,自动刷新" else log "发布目录缺少 single-exe,自动从归档解压" fi if ! tar -xzf "${RELEASE_ARCHIVE}" -C "${APP_DIR}/dist-exe"; then warn "无法解压发布归档: ${RELEASE_ARCHIVE}" return 1 fi chmod +x "${RELEASE_ENTRY_PATH}" 2>/dev/null || true } ensure_release_binary() { refresh_release_from_archive || return 1 if [[ ! -f "${RELEASE_ENTRY_PATH}" ]]; then warn "找不到 CentOS 7 single-exe: ${RELEASE_ENTRY_PATH}" return 1 fi chmod +x "${RELEASE_ENTRY_PATH}" 2>/dev/null || true [[ -x "${RELEASE_ENTRY_PATH}" ]] } start_single_exe() { ensure_release_binary || return 1 stop_port_listener "${PORT_VALUE}" || return 1 local runtime_log_dir="${CC_WEB_LOGS_DIR:-${RELEASE_DIR}/logs}" local output_log="${runtime_log_dir}/cc-web-single-exe.out" local pid_file="${runtime_log_dir}/cc-web-single-exe.pid" mkdir -p "${runtime_log_dir}" log "启动 CentOS 7 single-exe: ${RELEASE_ENTRY_PATH}" ( cd "${RELEASE_DIR}" exec nohup env \ CC_WEB_APP_DIR="${APP_DIR}" \ CC_WEB_PUBLIC_DIR="${RELEASE_DIR}/public" \ PORT="${PORT_VALUE}" \ "${RELEASE_ENTRY_PATH}" ) >"${output_log}" 2>&1 & local pid=$! printf '%s\n' "${pid}" > "${pid_file}" if ! wait_for_port "${PORT_VALUE}" 20; then warn "single-exe 未能在端口 ${PORT_VALUE} 上监听,详见: ${output_log}" kill "${pid}" 2>/dev/null || true wait "${pid}" 2>/dev/null || true return 1 fi log "single-exe 已启动,PID=${pid},日志: ${output_log}" return 0 } try_source_start() { if [[ ! -f "${ENTRY_PATH}" ]]; then warn "找不到源码入口: ${ENTRY_PATH}" return 1 fi if ! command -v node >/dev/null 2>&1; then warn "未检测到 Node.js,跳过源码启动" return 1 fi if ! command -v npm >/dev/null 2>&1; then warn "未检测到 npm,跳过源码启动" return 1 fi local node_major if ! node_major="$(node -p "Number(process.versions.node.split('.')[0])")"; then warn "Node.js 无法执行,跳过源码启动" return 1 fi if [[ "${node_major}" -lt 18 ]]; then warn "Node.js 版本过低($(node -v)),跳过源码启动" return 1 fi check_runtime_config install_pm2 || return 1 install_dependencies || return 1 start_or_restart_app || return 1 return 0 } main() { if [[ "$(id -u)" -eq 0 ]]; then warn "当前正在使用 root 用户执行。该服务会启动 Claude/Codex 子进程,root 运行风险较高,建议改用非 root 用户。" fi PORT_VALUE="$(resolve_port)" stop_port_listener "${PORT_VALUE}" || fail "无法释放端口 ${PORT_VALUE},已停止启动以避免继续使用旧进程" case "${START_MODE}" in single|release|bun) start_single_exe || fail "single-exe 启动失败,请检查 ${RELEASE_ENTRY_PATH}" log "完成。当前使用 CentOS 7 single-exe。" return ;; auto|source|pm2) ;; *) fail "不支持的 CC_WEB_START_MODE: ${START_MODE}(可选 auto/source/single)" ;; esac if try_source_start; then log "完成。若需要开机自启,请执行 pm2 startup,并按输出提示执行生成的命令。" return fi if [[ "${START_MODE}" != "auto" ]] || ! is_centos7; then fail "源码方式启动失败。Node.js 18+、npm、PM2、依赖和端口 ${PORT_VALUE} 均需可用。 $(node_upgrade_hint)" fi warn "源码方式启动失败,检测到 CentOS 7,回退到 dist-exe single-exe" stop_existing_pm2_app stop_port_listener "${PORT_VALUE}" || fail "无法释放端口 ${PORT_VALUE},已停止回退启动以避免继续使用旧进程" start_single_exe || fail "CentOS 7 single-exe 启动失败,请检查 ${RELEASE_ENTRY_PATH} 及日志" log "完成。当前使用 CentOS 7 single-exe。" } main "$@"