chore(dev): 增加开发机一键启动脚本 dev.sh
ci / 质量门禁 (push) Canceled after 0s

背景:
- 开发机上前端/后端/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 行为符合预期。
This commit is contained in:
Stev_Wang committed 2026-11-03 07:31:50 +08:00
1 parent 4d9eb980af
commit 46959fd5fc
3 files changed
+596

No files matched your search

Executable
+582
View File
@@ -0,0 +1,582 @@
#!/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