#!/usr/bin/env bash # # 灵犀 · 开发机服务启动脚本 # # 只负责启动本地开发服务(api / web / worker),不启动 PostgreSQL、Redis 或任何 # Docker 容器——这些按约定在开发前已经就绪。 # # 特性: # · 启动前自动检测端口占用,占用则结束对应进程(可用 --no-kill 改为直接失败) # · 后台启动 + 健康检查,返回后可用 logs/status/stop 管理 # · 停止时按进程组结束,并兜底清理端口,避免遗留孤儿进程 # · api(3000) / web(5173) / worker(无端口) # # 用法: # ./dev.sh 启动(默认 api + web + worker) # ./dev.sh start 同上 # ./dev.sh stop 停止全部服务并释放端口 # ./dev.sh restart 重启 # ./dev.sh status 查看端口、PID 与健康状态 # ./dev.sh logs [服务名] 跟踪日志(api|web|worker,默认全部) # ./dev.sh clean 停止并删除 .dev 状态与日志 # # 选项: # --api-only 只启动 api # --web-only 只启动 web # --no-worker 不启动 worker # --follow, -f 启动后前台跟踪日志,Ctrl+C 停止全部 # --no-kill 端口占用时不自动结束,直接报错退出 # -h, --help 显示帮助 # # 环境变量: # API_PORT 覆盖 api 端口(默认取 .env.local/.env 的 PORT,否则 3000) # WEB_PORT 覆盖 web 端口(默认 5173,需与 apps/web/vite.config.ts 一致) # NO_COLOR 关闭彩色输出 # set -Eeuo pipefail ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" cd "$ROOT" STATE_DIR="$ROOT/.dev" LOG_DIR="$STATE_DIR/logs" PID_DIR="$STATE_DIR/pids" API_FILTER="@lingxi/api" WEB_FILTER="@lingxi/web" WORKER_FILTER="@lingxi/worker" # shellcheck disable=SC2034 COMMAND="start" SERVICES=("api" "web" "worker") FOLLOW=0 AUTO_KILL=1 # ---------- 输出 ---------- if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then C_RESET=$'\033[0m'; C_DIM=$'\033[2m'; C_RED=$'\033[31m' C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m'; C_BLUE=$'\033[36m' else C_RESET=""; C_DIM=""; C_RED=""; C_GREEN=""; C_YELLOW=""; C_BLUE="" fi info() { printf '%s[dev]%s %s\n' "$C_BLUE" "$C_RESET" "$*"; } ok() { printf '%s[dev]%s %s\n' "$C_GREEN" "$C_RESET" "$*"; } warn() { printf '%s[dev]%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; } die() { printf '%s[dev]%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; exit 1; } dim() { printf '%s%s%s\n' "$C_DIM" "$*" "$C_RESET"; } usage() { cat <<'EOF_USAGE' 灵犀 · 开发机服务启动脚本(不启动 DB / Redis 容器) 用法: ./dev.sh 启动(默认 api + web + worker) ./dev.sh start 同上 ./dev.sh stop 停止全部服务并释放端口 ./dev.sh restart 重启 ./dev.sh status 查看端口、PID 与健康状态 ./dev.sh logs [服务名] 跟踪日志(api|web|worker,默认全部) ./dev.sh clean 停止并删除 .dev 状态与日志 选项: --api-only 只启动 api --web-only 只启动 web --no-worker 不启动 worker --follow, -f 启动后前台跟踪日志,Ctrl+C 停止全部 --no-kill 端口占用时不自动结束,直接报错退出 -h, --help 显示帮助 环境变量: API_PORT 覆盖 api 端口(默认取 .env.local/.env 的 PORT,否则 3000) WEB_PORT 覆盖 web 端口(默认 5173) NO_COLOR 关闭彩色输出 EOF_USAGE } # ---------- 参数 ---------- parse_args() { while (($# > 0)); do case "$1" in start|stop|restart|status|clean) COMMAND="$1" shift ;; logs) COMMAND="logs" shift if (($# > 0)) && [[ "$1" != -* ]]; then LOG_TARGET="$1" shift fi ;; --api-only) SERVICES=("api"); shift ;; --web-only) SERVICES=("web"); shift ;; --no-worker) local next=() for service in "${SERVICES[@]}"; do [[ "$service" == "worker" ]] || next+=("$service") done SERVICES=(${next[@]+"${next[@]}"}) shift ;; --worker) local has_worker=0 for service in "${SERVICES[@]}"; do [[ "$service" == "worker" ]] && has_worker=1; done ((has_worker == 1)) || SERVICES+=("worker") shift ;; --follow|-f) FOLLOW=1; shift ;; --no-kill) AUTO_KILL=0; shift ;; -h|--help) usage; exit 0 ;; *) die "未知参数:$1(用 --help 查看用法)" ;; esac done } # ---------- 端口与进程 ---------- listener_pids() { local port="$1" if command -v lsof >/dev/null 2>&1; then lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true elif command -v ss >/dev/null 2>&1; then ss -ltnpH "sport = :$port" 2>/dev/null | grep -oE 'pid=[0-9]+' | cut -d= -f2 | sort -u || true elif command -v fuser >/dev/null 2>&1; then fuser -n tcp "$port" 2>/dev/null | tr ' ' '\n' | grep -E '^[0-9]+$' || true else warn "缺少 lsof / ss / fuser,无法检测端口占用" true fi } pid_alive() { local pid="${1:-}" [[ -n "$pid" ]] || return 1 kill -0 "$pid" 2>/dev/null } pid_command() { local pid="$1" ps -p "$pid" -o command= 2>/dev/null | tr -d '\n' || true } process_brief() { local pid="$1" local name name="$(pid_command "$pid")" if [[ -z "$name" ]]; then printf 'PID %s' "$pid" else printf 'PID %s (%s)' "$pid" "${name:0:64}" fi } kill_port() { local port="$1" label="$2" pids pid pids="$(listener_pids "$port")" [[ -n "$pids" ]] || return 0 local brief="" for pid in $pids; do brief+="$(process_brief "$pid"); " done brief="${brief%; }" if ((AUTO_KILL == 0)); then die "端口 ${port}(${label})已被占用:${brief}。已指定 --no-kill。" fi warn "端口 ${port}(${label})被占用,结束进程:$brief" for pid in $pids; do kill -TERM "$pid" 2>/dev/null || true done for _ in $(seq 1 20); do [[ -z "$(listener_pids "$port")" ]] && { ok "端口 $port 已释放"; return 0; } sleep 0.25 done warn "端口 $port 优雅退出超时,强制结束" for pid in $pids; do kill -KILL "$pid" 2>/dev/null || true done sleep 0.3 [[ -z "$(listener_pids "$port")" ]] || die "端口 $port 仍被占用,无法启动" ok "端口 $port 已释放" } stop_pid() { local name="$1" local pidfile="$PID_DIR/$name.pid" local pid cmd [[ -f "$pidfile" ]] || return 1 pid="$(cat "$pidfile" 2>/dev/null || true)" rm -f "$pidfile" pid_alive "$pid" || return 1 cmd="$(pid_command "$pid")" if [[ -n "$cmd" ]]; then case "$cmd" in *tsx*|*vite*|*pnpm*|*node*|*worker*) ;; *) warn "跳过 PID ${pid}(命令不匹配开发服务):${cmd:0:80}" return 1 ;; esac else warn "无法读取 PID ${pid} 的命令(ps 不可用),仍尝试结束" fi info "停止 ${name}(PID ${pid})" kill -TERM -- "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true for _ in $(seq 1 20); do pid_alive "$pid" || return 0 sleep 0.25 done warn "$name 未在 5s 内退出,强制结束" kill -KILL -- "-$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true return 0 } # ---------- 服务元数据 ---------- svc_port() { case "$1" in api) printf '%s' "$API_PORT" ;; web) printf '%s' "$WEB_PORT" ;; *) printf '' ;; esac } svc_filter() { case "$1" in api) printf '%s' "$API_FILTER" ;; web) printf '%s' "$WEB_FILTER" ;; worker) printf '%s' "$WORKER_FILTER" ;; esac } svc_log() { printf '%s/%s.log' "$LOG_DIR" "$1" } svc_health_url() { case "$1" in api) printf 'http://127.0.0.1:%s/health' "$API_PORT" ;; web) printf 'http://127.0.0.1:%s/' "$WEB_PORT" ;; *) printf '' ;; esac } # ---------- 环境 ---------- read_env_port() { local key="${1:-PORT}" file value for file in "$ROOT/.env.local" "$ROOT/.env"; do [[ -f "$file" ]] || continue value="$(grep -E "^[[:space:]]*${key}[[:space:]]*=" "$file" 2>/dev/null | tail -n 1 | cut -d= -f2- | tr -d '"'"'"' ' || true)" [[ -n "$value" ]] && { printf '%s' "$value"; return 0; } done return 0 } load_env_files() { local file line key value for file in "$ROOT/.env.local" "$ROOT/.env"; do [[ -f "$file" ]] || continue while IFS= read -r line || [[ -n "$line" ]]; do line="${line%$'\r'}" [[ "$line" =~ ^[[:space:]]*# ]] && continue [[ "$line" =~ ^[[:space:]]*$ ]] && continue [[ "$line" == *=* ]] || continue key="${line%%=*}" key="${key#"${key%%[![:space:]]*}"}" key="${key%"${key##*[![:space:]]}"}" [[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue value="${line#*=}" value="${value%"${value##*[![:space:]]}"}" value="${value#\"}" value="${value%\"}" value="${value#\'}" value="${value%\'}" # shell 里已有的变量优先,不覆盖 if [[ -z "${!key:-}" ]]; then export "$key=$value" fi done < "$file" done } resolve_ports() { if [[ -z "${API_PORT:-}" ]]; then API_PORT="$(read_env_port PORT)" [[ -n "$API_PORT" ]] || API_PORT=3000 fi if [[ -z "${WEB_PORT:-}" ]]; then WEB_PORT=5173 fi } preflight() { command -v node >/dev/null 2>&1 || die "未找到 node,请先安装 Node.js 24.15.0(见 .nvmrc)" command -v pnpm >/dev/null 2>&1 || die "未找到 pnpm,请先安装 pnpm 10.x" [[ -d "$ROOT/node_modules" ]] || die "未找到 node_modules,请先执行 pnpm install" local required current required="$(cat "$ROOT/.nvmrc" 2>/dev/null | tr -d 'v[:space:]' || true)" current="$(node -p 'process.versions.node' 2>/dev/null || true)" if [[ -n "$required" && -n "$current" ]]; then if [[ "${current%%.*}" != "${required%%.*}" ]]; then warn "当前 Node.js ${current},.nvmrc 要求 ${required},建议先 nvm use" fi fi if [[ ! -f "$ROOT/packages/shared/dist/index.js" ]]; then info "检测到 packages/shared 未构建,自动构建一次…" pnpm --filter @lingxi/shared build >/dev/null ok "packages/shared 构建完成" fi if [[ ! -f "$ROOT/.env" && ! -f "$ROOT/.env.local" ]]; then warn "未找到 .env / .env.local,api 可能因缺少 DATABASE_URL、JWT 密钥而启动失败" fi } # ---------- 启动 / 停止 ---------- start_service() { local name="$1" filter port log pidfile pid filter="$(svc_filter "$name")" port="$(svc_port "$name")" log="$(svc_log "$name")" pidfile="$PID_DIR/$name.pid" if [[ -f "$pidfile" ]] && pid_alive "$(cat "$pidfile" 2>/dev/null || true)"; then if [[ -n "$port" ]] && curl -fsS --max-time 2 "$(svc_health_url "$name")" >/dev/null 2>&1; then info "$name 已在运行(PID $(cat "$pidfile")),跳过" return 0 fi warn "$name 的 PID 记录仍存在但服务未就绪,清理后重启" stop_pid "$name" || true fi rm -f "$pidfile" if [[ -n "$port" ]]; then kill_port "$port" "$name" fi mkdir -p "$LOG_DIR" "$PID_DIR" : > "$log" set -m case "$name" in api) ( cd "$ROOT" && exec env PORT="$API_PORT" pnpm --filter "$filter" dev ) >>"$log" 2>&1 & ;; web) ( cd "$ROOT" && exec pnpm --filter "$filter" dev -- --port "$WEB_PORT" --strictPort ) >>"$log" 2>&1 & ;; worker) ( cd "$ROOT" && exec pnpm --filter "$filter" dev ) >>"$log" 2>&1 & ;; esac pid=$! set +m echo "$pid" > "$pidfile" disown "$pid" 2>/dev/null || true if [[ -n "$port" ]]; then ok "已启动 ${name}(PID ${pid},端口 ${port})" else ok "已启动 ${name}(PID ${pid})" fi } wait_healthy() { local name="$1" url pidfile pid timeout=90 elapsed=0 log url="$(svc_health_url "$name")" pidfile="$PID_DIR/$name.pid" log="$(svc_log "$name")" if [[ -z "$url" ]]; then # 无端口的服务(worker):等成功日志;tsx watch 崩溃后进程仍在,需看日志 pid="$(cat "$pidfile" 2>/dev/null || true)" local i for i in $(seq 1 60); do if grep -q "启动失败" "$log" 2>/dev/null; then warn "$name 启动失败,最近日志:" tail -n 25 "$log" >&2 || true return 1 fi if grep -q "worker 已启动" "$log" 2>/dev/null; then ok "$name 运行中" return 0 fi pid_alive "$pid" || { warn "$name 启动后进程已退出,最近日志:" tail -n 20 "$log" >&2 || true return 1 } sleep 0.25 done warn "$name 在 15s 内未输出启动成功日志,最近日志:" tail -n 20 "$log" >&2 || true return 1 fi printf '%s[dev]%s 等待 %s 就绪 %s' "$C_BLUE" "$C_RESET" "$name" "$url" while ((elapsed < timeout * 4)); do if curl -fsS --max-time 2 "$url" >/dev/null 2>&1; then printf '\n' ok "$name 就绪:$url" return 0 fi pid="$(cat "$pidfile" 2>/dev/null || true)" if ! pid_alive "$pid"; then printf '\n' warn "$name 进程已退出,最近日志:" tail -n 30 "$log" >&2 || true return 1 fi printf '.' sleep 0.25 elapsed=$((elapsed + 1)) done printf '\n' warn "$name 在 ${timeout}s 内未就绪,最近日志:" tail -n 30 "$log" >&2 || true return 1 } TAIL_PID="" cleanup_follow() { printf '\n' if [[ -n "${TAIL_PID:-}" ]] && kill -0 "$TAIL_PID" 2>/dev/null; then kill "$TAIL_PID" 2>/dev/null || true fi stop_services exit 0 } start_services() { preflight mkdir -p "$LOG_DIR" "$PID_DIR" local service failed=0 for service in "${SERVICES[@]}"; do start_service "$service" || failed=1 done for service in "${SERVICES[@]}"; do wait_healthy "$service" || failed=1 done if ((failed != 0)); then warn "有服务未正常启动,已停止本次启动的服务;日志保留在 ${LOG_DIR}" stop_services >/dev/null 2>&1 || true exit 1 fi printf '\n' ok "全部服务已启动" printf ' %sAPI%s http://127.0.0.1:%s %s(健康检查 /health)%s\n' "$C_BLUE" "$C_RESET" "$API_PORT" "$C_DIM" "$C_RESET" if [[ " ${SERVICES[*]} " == *" web "* ]]; then printf ' %sWeb%s http://127.0.0.1:%s\n' "$C_BLUE" "$C_RESET" "$WEB_PORT" fi printf ' %s日志%s %s\n' "$C_BLUE" "$C_RESET" "$LOG_DIR" dim " ./dev.sh status | ./dev.sh logs | ./dev.sh stop" if ((FOLLOW == 1)); then local files=() for service in "${SERVICES[@]}"; do files+=("$(svc_log "$service")"); done sleep 0.3 # 后台 tail + wait:前台 tail 会阻塞信号 trap,导致 Ctrl+C 不生效 tail -n 20 -F "${files[@]}" & TAIL_PID=$! trap cleanup_follow INT TERM wait "$TAIL_PID" || true stop_services fi } stop_services() { local service for service in worker api web; do stop_pid "$service" || true done # 兜底:即使 pid 文件丢失,也释放监听端口 [[ -n "${API_PORT:-}" ]] && kill_port "$API_PORT" "api" || true [[ -n "${WEB_PORT:-}" ]] && kill_port "$WEB_PORT" "web" || true rm -f "$PID_DIR"/*.pid 2>/dev/null || true ok "开发服务已全部停止" } status_services() { local service port pid pidfile state url code printf '%-8s %-8s %-6s %-10s %s\n' "服务" "状态" "端口" "PID" "地址/健康" for service in api web worker; do port="$(svc_port "$service")" pidfile="$PID_DIR/$service.pid" pid="" [[ -f "$pidfile" ]] && pid="$(cat "$pidfile" 2>/dev/null || true)" url="$(svc_health_url "$service")" state="stopped" code="-" if [[ -n "$port" ]] && [[ -n "$(listener_pids "$port")" ]]; then state="running" pid="$(listener_pids "$port" | head -n 1)" if [[ -n "$url" ]]; then # 启动瞬间 Vite 可能正在做依赖预构建,重试几次再判定 for _ in 1 2 3 4 5; do code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 2 "$url" 2>/dev/null || true)" [[ -n "$code" && "$code" != "000" ]] && break sleep 0.2 done [[ -n "$code" ]] || code="-" fi elif pid_alive "$pid"; then state="running" else pid="-" fi printf '%-8s %-8s %-6s %-10s %s\n' \ "$service" "$state" "${port:--}" "${pid:--}" "${url:--} ${code}" done } logs_services() { local files=() service if [[ -n "${LOG_TARGET:-}" ]]; then [[ "$LOG_TARGET" == "api" || "$LOG_TARGET" == "web" || "$LOG_TARGET" == "worker" ]] \ || die "未知服务:${LOG_TARGET}(可选 api / web / worker)" [[ -f "$(svc_log "$LOG_TARGET")" ]] || die "日志不存在:$(svc_log "$LOG_TARGET")" files=("$(svc_log "$LOG_TARGET")") else for service in api web worker; do [[ -f "$(svc_log "$service")" ]] && files+=("$(svc_log "$service")") done ((${#files[@]} > 0)) || die "暂无日志,先执行 ./dev.sh start" fi tail -n 50 -F "${files[@]}" } clean_state() { stop_services rm -rf "$STATE_DIR" ok "已删除 .dev 目录" } # ---------- 主流程 ---------- parse_args "$@" load_env_files resolve_ports case "$COMMAND" in start) start_services ;; stop) stop_services ;; restart) stop_services; start_services ;; status) status_services ;; logs) logs_services ;; clean) clean_state ;; *) usage; die "未知命令:$COMMAND" ;; esac