背景: - 开发机上前端/后端/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:
1 parent
4d9eb980af
commit
46959fd5fc
3 files changed
+596
No files matched your search
@@ -75,3 +75,5 @@ test-results/
|
|||||||
design/
|
design/
|
||||||
# Vite 缓存
|
# Vite 缓存
|
||||||
.vite/
|
.vite/
|
||||||
|
# dev.sh 运行状态与日志
|
||||||
|
.dev/
|
||||||
@@ -79,6 +79,18 @@ pnpm db:seed # 幂等写入 system_configs 基线
|
|||||||
pnpm dev # 并行启动 api(3000) / web(5173) / worker
|
pnpm dev # 并行启动 api(3000) / web(5173) / worker
|
||||||
```
|
```
|
||||||
|
|
||||||
|
也可以使用一键脚本(后台启动、自动检测并释放端口、带健康检查):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./dev.sh # 启动 api + web + worker
|
||||||
|
./dev.sh status # 端口 / PID / 健康状态
|
||||||
|
./dev.sh logs [api|web|worker] # 跟踪日志(默认全部)
|
||||||
|
./dev.sh stop # 停止并释放 3000 / 5173
|
||||||
|
./dev.sh --help # 全部参数
|
||||||
|
```
|
||||||
|
|
||||||
|
> `dev.sh` 只负责开发服务,不启动 PostgreSQL / Redis 或任何 Docker 容器;这两者按约定在开发前已就绪。
|
||||||
|
|
||||||
- **本地数据库**:直接复用本机已有 Docker 服务 `docker-postgres-1`(PostgreSQL 18,`127.0.0.1:5432`),库名 `lingxi`,用户 `postgres`,密码 `postgres`;Redis 复用 `docker-redis-1`(`127.0.0.1:6379`)。请先确认这两个容器已启动,本仓库不会另起 PG/Redis。
|
- **本地数据库**:直接复用本机已有 Docker 服务 `docker-postgres-1`(PostgreSQL 18,`127.0.0.1:5432`),库名 `lingxi`,用户 `postgres`,密码 `postgres`;Redis 复用 `docker-redis-1`(`127.0.0.1:6379`)。请先确认这两个容器已启动,本仓库不会另起 PG/Redis。
|
||||||
- **对象存储**:使用公网 RustFS(`https://oss.xinsenidea.com`,桶 `lingxi-dev`);A0 的 `assets / renders / temp` 三类产物暂共用该桶,待 A1 按前缀拆分。真实 access key / secret 只写入本地 `.env.local`(已 gitignore),仓库内只保留占位。
|
- **对象存储**:使用公网 RustFS(`https://oss.xinsenidea.com`,桶 `lingxi-dev`);A0 的 `assets / renders / temp` 三类产物暂共用该桶,待 A1 按前缀拆分。真实 access key / secret 只写入本地 `.env.local`(已 gitignore),仓库内只保留占位。
|
||||||
- **API 文档**:启动 api 后访问 `http://127.0.0.1:3000/api/docs`,OpenAPI JSON 在 `openapi/lingxi.openapi.json`。
|
- **API 文档**:启动 api 后访问 `http://127.0.0.1:3000/api/docs`,OpenAPI JSON 在 `openapi/lingxi.openapi.json`。
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user