Files
cc-web/start.sh

600 lines
15 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 22root 用户可去掉 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 22root 用户可去掉 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 "$@"