Files
lingxi/dev.sh
T
Stev_Wang 46959fd5fc
ci / 质量门禁 (push) Canceled after 0s
chore(dev): 增加开发机一键启动脚本 dev.sh
背景:
- 开发机上前端/后端/worker 需要分别启动,端口被旧进程占用时排查成本高;
  PostgreSQL / Redis 已按约定提前以 Docker 启动,脚本不再负责。

改动:
- 新增 ./dev.sh:后台启动 api(3000)/web(5173)/worker,启动前自动检测并结束
  端口占用进程(--no-kill 可改为直接失败),等待健康检查后返回。
- 提供 start/stop/restart/status/logs/clean 子命令;stop 按进程组结束并兜底
  释放端口;支持 --api-only/--web-only/--no-worker/--follow。
- 自动加载仓库根 .env.local/.env 并导出给子进程(worker 读取 REDIS_URL 需要)。
- .dev/ 纳入 gitignore;README 本地开发章节补充脚本用法。

影响面:
- 仅新增开发工具与文档,不影响构建、运行时与 CI;`pnpm dev` 保持原行为。

回滚方式:
- 回滚本提交即可,无数据与配置变更。

验证证据:
- bash -n 通过;
- ./dev.sh start / status / stop / restart / clean / --follow 实测;
- 占用 3000/5173 的假服务被自动结束并成功启动;
- --no-kill、--api-only、--no-worker 行为符合预期。
2026-11-03 07:31:50 +08:00

583 lines
17 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
#
# 灵犀 · 开发机服务启动脚本
#
# 只负责启动本地开发服务(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