Files
lingxi/dev.sh
T
Stev_Wang 7824bc7b32
ci / 质量门禁 (push) Canceled after 0s
fix(dev): 明确启动不重置数据库并新增安全 db:init
背景:开发机每次重启后被反馈需要重新用默认密码登录并强改,排查确认仓库启动链路(dev.sh / pnpm dev / API / worker / db:seed)均不重置密码;只有 db:reset / migrate reset / 重建数据卷会恢复种子密码。A2 收尾时为干净截图执行过 db:reset,是最近一次重置的直接原因。

改动:
- 新增 pnpm db:init = prisma migrate deploy + 幂等 seed,作为首次/迁移后的安全初始化;不重置数据、不覆盖已有密码。
- README / dev.sh / AGENTS 命令契约 / SEED.md / A1·A2 实施报告明确:日常启动不需要 DB 命令,db:reset 仅用于清库。
- 实测:对已改密的 admin 执行 db:init 后 password_hash 与 must_change_password 均未变化。

验证:pnpm format:check / pnpm lint 通过;db:init 实际执行通过。
2026-11-03 13:37:34 +08:00

603 lines
18 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")
# auto:终端下启动后直接跟随日志;非交互(脚本/CI)默认后台
FOLLOW="auto"
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 容器)
说明:本脚本只启停 api / web / worker,不执行迁移、seed 或 db:reset;
已有账号密码不会被改动。需要清库请显式运行 pnpm db:reset。
用法:
./dev.sh 启动服务并前台跟随运行日志(Ctrl+C 停止全部)
./dev.sh start 同上
./dev.sh start -d 后台启动,立即返回
./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
-d, --detach 后台启动,不跟随日志
-f, --follow 强制前台跟随日志(默认在终端下开启)
--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 ;;
-d|--detach)
FOLLOW=0; 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%\'}"
# VITE_* 交给前端自己的 envDir 处理,避免覆盖 Vite 的构建/运行配置
case "$key" in VITE_*) continue ;; esac
# 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
printf '\n'
dim "以下为运行日志,按 Ctrl+C 停止全部服务"
printf '\n'
# 后台 tail + wait:前台 tail 会阻塞信号 trap,导致 Ctrl+C 不生效
tail -n 20 -F "${files[@]}" &
TAIL_PID=$!
trap cleanup_follow INT TERM
wait "$TAIL_PID" || true
stop_services
else
printf '\n'
dim "后台运行中:./dev.sh logs 查看日志,./dev.sh stop 停止服务"
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 "$@"
if [[ "$FOLLOW" == "auto" ]]; then
if [[ -t 1 ]]; then FOLLOW=1; else FOLLOW=0; fi
fi
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