600 lines
15 KiB
Bash
Executable File
600 lines
15 KiB
Bash
Executable File
#!/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 "$@"
|