7ab1606
ZhenYi
feat(app): implement multi-page application structure with routing and theme management
7ab1606
#!/usr/bin/env bash
# GitData 本地开发编排脚本(macOS / Linux)。
# 由 justfile 调用:just <cmd> [svc...] -> scripts/dev.sh <cmd> [svc...]
# 依赖服务(PostgreSQL / Redis / S3)由 `just infra` 通过 docker compose 托管;
# RocketMQ 不在 compose 里,需宿主机自行提供(端口见 rocketmq_port)。
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# 二进制目录跟随 CARGO_TARGET_DIR(cargo 约定:相对路径按调用时的工作目录解析,
# just 在仓库根目录调用 cargo,所以这里也按 ROOT 解析)。
# DEV_DIR 保持在仓库内的 target/dev:日志 / pid / 本地仓库不随 target 目录搬家。
if [ -n "${CARGO_TARGET_DIR:-}" ]; then
case "$CARGO_TARGET_DIR" in
/*) TARGET_DIR="$CARGO_TARGET_DIR" ;;
"~") TARGET_DIR="$HOME" ;;
"~/"*) TARGET_DIR="$HOME/${CARGO_TARGET_DIR#\~/}" ;;
*) TARGET_DIR="$ROOT/$CARGO_TARGET_DIR" ;;
esac
else
TARGET_DIR="$ROOT/target"
fi
DEV_DIR="$ROOT/target/dev"
LOG_DIR="$DEV_DIR/logs"
PID_DIR="$DEV_DIR/pids"
BIN_DIR="$TARGET_DIR/debug"
COMPOSE_FILE="$ROOT/docker-compose.dev.yml"
ORDER="apihost"
BACKENDS="apihost"
INFRA="postgresql:5432 redis:6379 s3:9000"
if [ -t 1 ]; then
C_WARN="$(printf '\033[33m')"; C_ERR="$(printf '\033[31m')"; C_OFF="$(printf '\033[0m')"
else
C_WARN=''; C_ERR=''; C_OFF=''
fi
info() { printf '%s\n' "$*"; }
warn() { printf '%s%s%s\n' "$C_WARN" "$*" "$C_OFF"; }
fail() { printf '%s%s%s\n' "$C_ERR" "$*" "$C_OFF" >&2; }
contains() {
local needle="$1"; shift
local item
for item in $*; do
[ "$item" = "$needle" ] && return 0
done
return 1
}
port_of() {
case "$1" in
apihost) echo 7051 ;;
*) echo 0 ;;
esac
}
alias_of() {
case "$1" in
api) echo apihost ;;
*) echo "$1" ;;
esac
}
url_of() {
case "$1" in
apihost) echo "http://127.0.0.1:7051 (Swagger: /api/docs)" ;;
esac
}
ensure_dirs() { mkdir -p "$DEV_DIR" "$LOG_DIR" "$PID_DIR"; }
# macOS 默认没有 coreutils 的 timeout;有就直用,否则用后台轮询兜底。
run_with_timeout() {
local secs="$1"; shift
if command -v timeout >/dev/null 2>&1; then
timeout "$secs" "$@"
return $?
fi
if command -v gtimeout >/dev/null 2>&1; then
gtimeout "$secs" "$@"
return $?
fi
"$@" &
local pid=$! elapsed=0
while [ "$elapsed" -lt "$((secs * 4))" ]; do
if ! kill -0 "$pid" 2>/dev/null; then
wait "$pid"
return $?
fi
sleep 0.25
elapsed=$((elapsed + 1))
done
kill -TERM "$pid" 2>/dev/null
sleep 1
kill -KILL "$pid" 2>/dev/null
wait "$pid" 2>/dev/null
return 124
}
# just 以非登录 shell 执行 recipe,常见工具链目录可能不在 PATH 里。
# 这里把 macOS / Linux 的惯用位置补进来(仅在命令缺失时追加)。
ensure_path() {
local candidates='' dir
case "$(uname -s)" in
Darwin)
candidates="$HOME/.cargo/bin $HOME/.bun/bin /opt/homebrew/bin /opt/homebrew/opt/rust/bin /usr/local/bin /usr/local/opt/rust/bin"
;;
*)
candidates="$HOME/.cargo/bin $HOME/.bun/bin /usr/local/bin"
;;
esac
# nvm 安装的 bun/node
for dir in "$HOME"/.nvm/versions/node/*/bin; do
[ -d "$dir" ] && candidates="$candidates $dir"
done
# volta / fnm / asdf 等常见版本管理器
for dir in "$HOME/.volta/bin" "$HOME/.local/share/fnm/aliases/default/bin" "$HOME/.asdf/shims"; do
[ -d "$dir" ] && candidates="$candidates $dir"
done
for dir in $candidates; do
[ -d "$dir" ] || continue
case ":$PATH:" in
*":$dir:"*) ;;
*) PATH="$PATH:$dir" ;;
esac
done
export PATH
}
pid_file() { echo "$PID_DIR/$1.pid"; }
running_pid() {
local f p
f="$(pid_file "$1")"
[ -f "$f" ] || { echo 0; return; }
p="$(tr -d '[:space:]' < "$f")"
case "$p" in
''|*[!0-9]*) echo 0 ;;
*) if kill -0 "$p" 2>/dev/null; then echo "$p"; else echo 0; fi ;;
esac
}
# 判断 127.0.0.1:port 是否在监听。
# 优先查内核 socket 表(瞬时、可靠);连不上时再退回带超时的连接探测。
# 注意:某些环境(WSL2 mirrored 网络、容器里的 iptables DROP)对未监听端口不回 RST,
# 纯 /dev/tcp 连接会永久挂住,所以任何连接探测都必须带超时。
port_open() {
local port="${1:-0}"
[ "$port" = "0" ] && return 1
if command -v ss >/dev/null 2>&1; then
if ss -ltn 2>/dev/null | awk -v p="$port" '{ n = split($4, a, ":"); if (a[n] == p) found = 1 } END { exit !found }'; then
return 0
fi
return 1
fi
if command -v lsof >/dev/null 2>&1; then
lsof -nP -iTCP:"$port" -sTCP:LISTEN -t >/dev/null 2>&1 && return 0
return 1
fi
if command -v nc >/dev/null 2>&1; then
run_with_timeout 2 nc -z 127.0.0.1 "$port" >/dev/null 2>&1 && return 0
return 1
fi
run_with_timeout 2 bash -c "exec 3<>/dev/tcp/127.0.0.1/$port" >/dev/null 2>&1
}
port_owner() {
local port="$1"
if command -v lsof >/dev/null 2>&1; then
lsof -nP -iTCP:"$port" -sTCP:LISTEN -t 2>/dev/null | head -n1
elif command -v ss >/dev/null 2>&1; then
ss -ltnp 2>/dev/null | awk -v p=":$port" '$4 ~ p {print $NF}' | sed -E 's/.*pid=([0-9]+).*/\1/' | head -n1
fi
}
health_ok() {
local name="$1" port
port="$(port_of "$name")"
[ "$port" = "0" ] && return 0
case "$name" in
*)
if command -v curl >/dev/null 2>&1; then
# --noproxy '*':有些开发机设置了 http_proxy,否则 localhost 也会被代理
curl -fsS --noproxy '*' --max-time 3 "http://127.0.0.1:$port/healthz" >/dev/null 2>&1
else
port_open "$port"
fi
;;
esac
}
config_url() {
local scheme="$1" found=''
if [ -d "$ROOT/config/local" ]; then
found="$(grep -rhoE "\"${scheme}://[^\"]+\"" "$ROOT/config/local" 2>/dev/null \
| head -n1 | tr -d '"')"
fi
if [ -z "$found" ] && [ -f "$ROOT/.env" ]; then
found="$(grep -E "^[[:space:]]*(export[[:space:]]+)?[A-Z_]*URL[[:space:]]*=[[:space:]]*.${scheme}://" "$ROOT/.env" 2>/dev/null \
| head -n1 | sed -E 's/^[^=]*=[[:space:]]*//' | tr -d '"'"'"'')"
fi
printf '%s\n' "$found"
}
# ---------- check ----------
# RocketMQ 是启动必需项,但由宿主机提供、不在 docker compose 里。
rocketmq_port() {
local file port
for file in "$ROOT/config/local/rocketmq.json" "$ROOT/config/default/rocketmq.json"; do
[ -f "$file" ] || continue
port="$(grep -oE '"name_server"[[:space:]]*:[[:space:]]*"[^"]*"' "$file" \
| head -n1 | sed -E 's/.*:([0-9]+)"/\1/')"
if [ -n "$port" ]; then printf '%s\n' "$port"; return 0; fi
done
printf '9876\n'
}
cmd_check() {
local ok=1 t pair name port mq_port
ensure_path
info '== 工具链 =='
for t in cargo; do
if command -v "$t" >/dev/null 2>&1; then
printf ' %-12s OK\n' "$t"
else
fail " $(printf '%-12s 缺失' "$t")"; ok=0
fi
done
if command -v curl >/dev/null 2>&1; then
printf ' %-12s OK\n' curl
else
warn " $(printf '%-12s 缺失,健康检查将退化为端口探测' curl)"
fi
printf ' %-12s %s\n' 'os' "$(uname -s)"
info '== 依赖服务(PostgreSQL / Redis / S3)=='
for pair in $INFRA; do
name="${pair%%:*}"; port="${pair##*:}"
if port_open "$port"; then
printf ' %-12s 127.0.0.1:%s OK\n' "$name" "$port"
else
fail " $(printf '%-12s 127.0.0.1:%s 不可用' "$name" "$port")"; ok=0
fi
done
mq_port="$(rocketmq_port)"
if port_open "$mq_port"; then
printf ' %-12s 127.0.0.1:%s OK\n' rocketmq "$mq_port"
else
fail " $(printf '%-12s 127.0.0.1:%s 不可用(需宿主机自行启动 RocketMQ)' rocketmq "$mq_port")"; ok=0
fi
info "== 后端二进制 ($BIN_DIR) =="
if [ -x "$BIN_DIR/app" ]; then
printf ' %-12s OK\n' app
else
warn " $(printf '%-12s 未构建' app)"; ok=0
fi
if [ "$ok" = "0" ]; then
info ''
info 'just infra 启动依赖服务(docker compose)'
info 'just build 构建全部后端二进制(前端产物需先 pnpm build 生成 view/dist)'
return 1
fi
return 0
}
# ---------- infra ----------
infra_ports_ready() {
local pair port
for pair in $INFRA; do
port="${pair##*:}"
port_open "$port" || return 1
done
return 0
}
cmd_infra() {
local action="${1:-up}" pair name port rc=0
# 三个端口都已就绪时不再动 docker(例如本机已用 brew / systemd 装好了依赖服务)
if [ "$action" = "up" ] && infra_ports_ready; then
info '[infra] 依赖服务已在运行(端口均已监听),跳过 docker compose'
for pair in $INFRA; do
name="${pair%%:*}"; port="${pair##*:}"
printf ' %-12s 127.0.0.1:%s OK\n' "$name" "$port"
done
return 0
fi
if ! command -v docker >/dev/null 2>&1; then
fail ' 未找到 docker,无法托管本地依赖服务'
info ' 二选一:'
info ' macOS: brew install --cask docker (或 colima: brew install colima && colima start)'
info ' Linux: 安装 docker engine + compose plugin'
info ' 也可自行启动 PostgreSQL/Redis/S3,或用 APP_* 环境变量指向已有实例'
return 1
fi
if ! docker compose version >/dev/null 2>&1; then
fail ' docker compose 不可用(需要 Compose v2)'
return 1
fi
if [ ! -f "$COMPOSE_FILE" ]; then
fail " 缺少 $COMPOSE_FILE"
return 1
fi
case "$action" in
up)
info "[infra] docker compose up -d ($COMPOSE_FILE)"
docker compose -f "$COMPOSE_FILE" up -d || rc=$?
;;
down)
info "[infra] docker compose down ($COMPOSE_FILE)"
docker compose -f "$COMPOSE_FILE" down || rc=$?
;;
esac
[ "$rc" != "0" ] && return "$rc"
sleep 2
for pair in $INFRA; do
name="${pair%%:*}"; port="${pair##*:}"
if port_open "$port"; then
printf ' %-12s 127.0.0.1:%s OK\n' "$name" "$port"
else
warn " $(printf '%-12s 127.0.0.1:%s 未就绪' "$name" "$port")"
fi
done
return 0
}
# ---------- 运行期环境 ----------
service_env() {
ensure_path
ensure_dirs
if [ -z "${APP_GIT__REPO_ROOT:-}" ]; then
APP_GIT__REPO_ROOT="$DEV_DIR/git/repos"
fi
export APP_GIT__REPO_ROOT
mkdir -p "$APP_GIT__REPO_ROOT"
}
# ---------- migrate ----------
cmd_migrate() {
ensure_path
service_env || return 1
if [ ! -x "$BIN_DIR/app" ]; then
fail " 缺少 $BIN_DIR/app,请先执行 just build"
return 1
fi
info '[migrate] 同步数据库 schema ...'
if ! "$BIN_DIR/app" migrate >"$LOG_DIR/migrate.log" 2>"$LOG_DIR/migrate.err.log"; then
fail ' [migrate] 失败,见 just logs migrate'
tail -n 15 "$LOG_DIR/migrate.err.log" 2>/dev/null
return 1
fi
info '[migrate] 完成'
}
# ---------- start / stop ----------
spawn_daemon() {
local name="$1" dir="$2" cmd="$3"
ensure_path
local out="$LOG_DIR/$name.log" err="$LOG_DIR/$name.err.log"
local pid
cd "$dir" || return 1
if command -v setsid >/dev/null 2>&1; then
nohup setsid $cmd >"$out" 2>"$err" </dev/null &
else
# macOS 无 setsid;nohup + 重定向已足够脱离调用方的管道句柄
nohup $cmd >"$out" 2>"$err" </dev/null &
fi
pid=$!
printf '%s\n' "$pid" > "$(pid_file "$name")"
cd "$ROOT" || true
printf '%s\n' "$pid"
}
start_one() {
local name="$1" port pid dir cmd
port="$(port_of "$name")"
pid="$(running_pid "$name")"
if [ "$pid" != "0" ] && health_ok "$name"; then
info "[$name] 已在运行 (pid $pid)"
return 1
fi
if port_open "$port"; then
warn "[$name] 端口 $port 已被 pid $(port_owner "$port" | tr -d '[:space:]') 占用,跳过"
return 1
fi
ensure_dirs
service_env || return 1
case "$name" in
*)
[ -x "$BIN_DIR/app" ] || { fail " 缺少 $BIN_DIR/app,请先执行 just build"; return 1; }
dir="$ROOT"
cmd="$BIN_DIR/app serve"
;;
esac
pid="$(spawn_daemon "$name" "$dir" "$cmd")" || { fail " $name 启动失败"; return 1; }
info "[$name] 启动 pid=$pid"
return 0
}
wait_ready() {
local name="$1" timeout="${2:-90}" waited=0
while [ "$waited" -lt "$timeout" ]; do
if health_ok "$name"; then return 0; fi
if [ "$(running_pid "$name")" = "0" ]; then return 1; fi
sleep 1; waited=$((waited + 1))
done
return 1
}
kill_tree() {
local pid="$1"
[ -z "$pid" ] || [ "$pid" = "0" ] && return 0
if command -v pkill >/dev/null 2>&1; then
pkill -TERM -P "$pid" 2>/dev/null
fi
kill -TERM "$pid" 2>/dev/null
local i=0
while [ "$i" -lt 120 ]; do
kill -0 "$pid" 2>/dev/null || return 0
sleep 1; i=$((i + 1))
done
if command -v pkill >/dev/null 2>&1; then
pkill -KILL -P "$pid" 2>/dev/null
fi
kill -KILL "$pid" 2>/dev/null
return 0
}
stop_stray() {
local name="$1" port="${2:-0}" owner exe
[ "$port" = "0" ] && return 0
owner="$(port_owner "$port" | tr -d '[:space:]')"
[ -z "$owner" ] && return 0
# Linux:/proc/PID/exe 可解析真实可执行文件;macOS 无 /proc,退化为命令行匹配
if [ -h "/proc/$owner/exe" ]; then
exe="$(readlink "/proc/$owner/exe" 2>/dev/null)"
case "$exe" in
"$BIN_DIR"/*) kill_tree "$owner"; info "[$name] 清理残留监听 $port (pid $owner)"; return 0 ;;
esac
fi
case "$(ps -o command= -p "$owner" 2>/dev/null)" in
*"$BIN_DIR"*) kill_tree "$owner"; info "[$name] 清理残留监听 $port (pid $owner)"; return 0 ;;
*"$ROOT"*) kill_tree "$owner"; info "[$name] 清理残留监听 $port (pid $owner)" ;;
*) warn "[$name] 端口 $port 仍被 pid $owner 占用(非本仓库进程,未处理)" ;;
esac
return 0
}
stop_one() {
local name="$1" port pid
port="$(port_of "$name")"
pid="$(running_pid "$name")"
if [ "$pid" != "0" ]; then
kill_tree "$pid"
info "[$name] 已停止 (pid $pid)"
else
info "[$name] 未在运行"
fi
rm -f "$(pid_file "$name")"
stop_stray "$name" "$port"
return 0
}
# ---------- service selection ----------
resolve_services() {
local raw part key seen out=''
if [ "$#" -eq 0 ]; then
printf '%s\n' $ORDER
return 0
fi
for raw in "$@"; do
# 支持逗号分隔与空格分隔
for part in $(printf '%s' "$raw" | tr ',' ' '); do
key="$(printf '%s' "$part" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')"
[ -z "$key" ] && continue
key="$(alias_of "$key")"
case "$key" in
all) out="$out $ORDER" ;;
backends) out="$out $BACKENDS" ;;
*)
if ! contains "$key" $ORDER; then
fail "未知服务: $part"
info "可用: $ORDER"
info "别名: api / backends"
return 1
fi
out="$out $key"
;;
esac
done
done
# 去重且按 ORDER 排序
for key in $ORDER; do
contains "$key" $out && printf '%s\n' "$key"
done
return 0
}
# ---------- up / down / status / logs ----------
cmd_up() {
local names
names="$(resolve_services "$@")" || return 1
[ -z "$names" ] && return 0
local needs_backend=0 name
for name in $names; do
contains "$name" "$BACKENDS" && needs_backend=1
done
service_env >/dev/null 2>&1
local pair pname pport
for pair in $INFRA; do
pname="${pair%%:*}"; pport="${pair##*:}"
port_open "$pport" || warn "[warn] $pname 未监听 127.0.0.1:$pport,可执行 just infra"
done
if [ "$needs_backend" = "1" ] && [ "${SKIP_MIGRATE:-0}" != "1" ]; then
cmd_migrate || return 1
fi
for name in $names; do
if start_one "$name"; then
if wait_ready "$name" 90; then
info "[$name] 就绪"
else
fail "[$name] 90s 内未就绪,见 just logs $name"
fi
fi
done
info ''
info '服务地址:'
for name in $names; do
printf ' %-10s %s\n' "$name" "$(url_of "$name")"
done
return 0
}
cmd_down() {
local names
names="$(resolve_services "$@")" || return 1
# 逆序停止
local reversed='' name
for name in $ORDER; do
contains "$name" "$names" && reversed="$name $reversed"
done
for name in $reversed; do
stop_one "$name"
done
return 0
}
cmd_status() {
local name pid port portstate state
printf '%-10s %-8s %-9s %s\n' SERVICE PID PORT STATE
for name in $ORDER; do
pid="$(running_pid "$name")"
port="$(port_of "$name")"
if port_open "$port"; then portstate=listen; else portstate=closed; fi
if [ "$pid" = "0" ]; then
state=stopped
elif health_ok "$name"; then
state=ready
else
state=starting/fail
fi
printf '%-10s %-8s %-9s %s\n' "$name" "$pid" "$portstate" "$state"
done
return 0
}
cmd_logs() {
local follow=0 names='' raw name suffix path
for raw in "$@"; do
case "$raw" in
-f|--follow) follow=1 ;;
*) names="$names $raw" ;;
esac
done
[ -z "$(printf '%s' "$names" | tr -d '[:space:]')" ] && names="apihost"
names="$(printf '%s' "$names" | tr ',' ' ')"
for name in $names; do
if ! contains "$name" $ORDER; then
fail "未知服务: $name"
return 1
fi
for suffix in log err.log; do
path="$LOG_DIR/$name.$suffix"
[ -s "$path" ] || continue
info "===== $name.$suffix (tail 80) ====="
tail -n 80 "$path"
done
done
if [ "$follow" = "1" ]; then
name="$(printf '%s' "$names" | awk '{print $1}')"
path="$LOG_DIR/$name.log"
if [ ! -f "$path" ]; then
fail "没有日志文件: $path"
return 1
fi
info "===== follow $name.log (Ctrl+C 退出) ====="
tail -n 0 -f "$path"
fi
return 0
}
# ---------- db / redis ----------
cmd_psql() {
local url
url="${APP_DATABASE__URL:-$(config_url postgres)}"
[ -z "$url" ] && url="postgres://postgres:postgres@127.0.0.1:5432/gitdata"
info "[psql] $url"
if ! command -v psql >/dev/null 2>&1; then
fail ' 未找到 psql 客户端'
info ' macOS: brew install postgresql@16'
return 1
fi
psql "$url"
}
cmd_redis() {
local url
url="${APP_REDIS__URL:-$(config_url redis)}"
[ -z "$url" ] && url="redis://127.0.0.1:6379"
info "[redis-cli] $url"
if ! command -v redis-cli >/dev/null 2>&1; then
fail ' 未找到 redis-cli'
info ' macOS: brew install redis'
return 1
fi
redis-cli -u "$url"
}
# ---------- dispatch ----------
case "${1:-status}" in
check) cmd_check ;;
infra) cmd_infra up ;;
infra-down) cmd_infra down ;;
migrate) cmd_migrate ;;
up) shift; cmd_up "$@" ;;
down) shift; cmd_down "$@" ;;
restart) shift; cmd_down "$@"; SKIP_MIGRATE=1 cmd_up "$@" ;;
status) cmd_status ;;
logs) shift; cmd_logs "$@" ;;
psql) cmd_psql ;;
redis) cmd_redis ;;
*)
fail "未知命令: ${1:-}"
info '可用: check/infra/infra-down/migrate/up/down/restart/status/logs/psql/redis'
exit 1
;;
esac