ci / 质量门禁 (push) Canceled after 0s
背景:开发机每次重启后被反馈需要重新用默认密码登录并强改,排查确认仓库启动链路(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 实际执行通过。
603 lines
18 KiB
Bash
Executable File
603 lines
18 KiB
Bash
Executable File
#!/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
|