#!/usr/bin/env bash # GitData 本地开发编排脚本(macOS / Linux)。 # 由 justfile 调用:just [svc...] -> scripts/dev.sh [svc...] # 依赖服务(PostgreSQL / Redis / S3 / Meilisearch)由 `just infra` 通过 docker compose 托管。 set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DEV_DIR="$ROOT/target/dev" LOG_DIR="$DEV_DIR/logs" PID_DIR="$DEV_DIR/pids" BIN_DIR="$ROOT/target/debug" DEV_HOST_KEY="$DEV_DIR/ssh_host_ed25519" COMPOSE_FILE="$ROOT/docker-compose.dev.yml" ORDER="apihost githost im synchost mails webhook web" BACKENDS="apihost githost im synchost mails webhook" INFRA="postgresql:5432 redis:6379 s3:9000 meilisearch:7700" 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 4081 ;; githost) echo 9418 ;; im) echo 4082 ;; synchost) echo 4083 ;; mails) echo 4084 ;; webhook) echo 3005 ;; web) echo 5173 ;; *) echo 0 ;; esac } extra_port_of() { case "$1" in githost) echo 4022 ;; *) echo 0 ;; esac } alias_of() { case "$1" in api) echo apihost ;; git) echo githost ;; chat) echo im ;; sync) echo synchost ;; mail) echo mails ;; hook) echo webhook ;; ui) echo web ;; *) echo "$1" ;; esac } url_of() { case "$1" in apihost) echo "http://127.0.0.1:4081 (Swagger: /api/docs)" ;; githost) echo "http://127.0.0.1:9418 ssh://git@127.0.0.1:4022" ;; im) echo "ws://127.0.0.1:4082" ;; synchost) echo "http://127.0.0.1:4083/healthz" ;; mails) echo "http://127.0.0.1:4084/healthz" ;; webhook) echo "http://127.0.0.1:3005" ;; web) echo "http://127.0.0.1:5173" ;; 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 web) port_open "$port" ;; *) 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 [ -f "$ROOT/config/local.toml" ]; then found="$(grep -E "^[[:space:]]*url[[:space:]]*=[[:space:]]*\"${scheme}://" "$ROOT/config/local.toml" 2>/dev/null \ | head -n1 | sed -E 's/^[^"]*"([^"]+)".*$/\1/')" 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" } config_has_key() { [ -f "$ROOT/config/local.toml" ] || return 1 grep -qE "^[[:space:]]*$1[[:space:]]*=" "$ROOT/config/local.toml" 2>/dev/null } # ---------- check ---------- cmd_check() { local ok=1 t pair name port ensure_path ensure_path info '== 工具链 ==' for t in cargo bun ssh-keygen; 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 / Meilisearch)==' 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 info '== 后端二进制 ==' for name in $BACKENDS; do if [ -x "$BIN_DIR/$name" ]; then printf ' %-12s OK\n' "$name" else warn " $(printf '%-12s 未构建' "$name")"; ok=0 fi done if [ "$ok" = "0" ]; then info '' info 'just infra 启动依赖服务(docker compose)' info 'just build 构建全部后端二进制(前端由 build.rs 自动构建并内嵌)' 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/Meilisearch,或用 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 } # ---------- host key / env ---------- ensure_host_key() { if [ -f "$DEV_HOST_KEY" ]; then printf '%s\n' "$DEV_HOST_KEY"; return 0 fi ensure_dirs info "[hostkey] 生成本地 SSH host key: $DEV_HOST_KEY" ssh-keygen -q -t ed25519 -f "$DEV_HOST_KEY" -N '' -C gitdata-dev >/dev/null 2>&1 if [ ! -f "$DEV_HOST_KEY" ]; then fail ' ssh-keygen 执行失败' return 1 fi printf '%s\n' "$DEV_HOST_KEY" } service_env() { ensure_path ensure_dirs if [ -z "${APP_GIT__SSH__HOST_KEY_PATH:-}" ]; then APP_GIT__SSH__HOST_KEY_PATH="$(ensure_host_key)" || return 1 fi if [ -z "${APP_GIT__REPO_ROOT:-}" ]; then APP_GIT__REPO_ROOT="$DEV_DIR/git/repos" fi if [ -z "${APP_GIT__OBJECTS_ROOT:-}" ]; then APP_GIT__OBJECTS_ROOT="$DEV_DIR/git/objects" fi export APP_GIT__SSH__HOST_KEY_PATH APP_GIT__REPO_ROOT APP_GIT__OBJECTS_ROOT mkdir -p "$APP_GIT__REPO_ROOT" "$APP_GIT__OBJECTS_ROOT" # macOS 的 HOME 通常不可写 /opt;若 local.toml 指向 /opt 又不可写则给出提示 if ! config_has_key 'repo_root'; then return 0 fi } # ---------- migrate ---------- cmd_migrate() { ensure_path service_env || return 1 if [ ! -x "$BIN_DIR/migrate" ]; then fail ' 缺少 target/debug/migrate,请先执行 just build' return 1 fi info '[migrate] 同步数据库 schema ...' if ! "$BIN_DIR/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 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" "$out" 2>"$err" "$(pid_file "$name")" cd "$ROOT" || true printf '%s\n' "$pid" } # 前端依赖自检: # 1) node_modules 缺失 -> bun install # 2) 原生绑定与当前平台不匹配(跨平台切换工作区常见)-> 重装 # 3) bun 本身不是本平台可执行文件(例如 WSL 里装的是 bun.exe)-> 明确报错 ensure_frontend_deps() { local dir="$1" name="$2" ensure_path if ! command -v bun >/dev/null 2>&1; then fail " 未找到 bun,无法启动 $name(macOS: brew install oven-sh/bun/bun)" return 1 fi # bun 必须是本平台原生可执行文件;WSL 里指向 bun.exe 时无法跑 Linux 原生模块 local bun_path bun_kind bun_path="$(command -v bun)" bun_kind="$(file -b "$bun_path" 2>/dev/null || echo unknown)" case "$bun_kind" in *PE32*|*MS-DOS*) if [ "$(uname -s)" != "Darwin" ]; then fail " bun 指向 Windows 可执行文件($bun_path)" info " 该环境无法运行 Linux/macOS 原生前端依赖,请安装本平台原生 bun:" info " curl -fsSL https://bun.sh/install | bash" return 1 fi ;; esac if [ ! -d "$dir/node_modules" ] || [ ! -e "$dir/node_modules/.bin/vite" ]; then info "[$name] 安装前端依赖 ..." (cd "$dir" && bun install) || { fail " [$name] bun install 失败"; return 1; } return 0 fi # 冒烟测试本地 vite:原生绑定不匹配时会直接失败 if (cd "$dir" && run_with_timeout 60 ./node_modules/.bin/vite --version >/dev/null 2>&1); then return 0 fi warn "[$name] 本地 vite 无法运行(原生绑定与当前平台不匹配),重新安装依赖 ..." (cd "$dir" && bun install) || { fail " [$name] bun install 失败"; return 1; } if (cd "$dir" && run_with_timeout 60 ./node_modules/.bin/vite --version >/dev/null 2>&1); then return 0 fi fail " [$name] 依赖仍然不可用,见 just logs $name" return 1 } 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 web) dir="$ROOT/web" [ -d "$dir" ] || { fail " 缺少目录 $dir"; return 1; } ensure_frontend_deps "$dir" "$name" || return 1 # --strictPort:端口被占用时直接失败,避免 vite 悄悄换到别的端口 cmd="bun run dev -- --strictPort --port $(port_of "$name")" ;; *) [ -x "$BIN_DIR/$name" ] || { fail " 缺少 $BIN_DIR/$name,请先执行 just build"; return 1; } dir="$ROOT" cmd="$BIN_DIR/$name" ;; 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 10 ]; 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 *"$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" stop_stray "$name" "$(extra_port_of "$name")" 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" ;; frontends) out="$out web" ;; *) if ! contains "$key" $ORDER; then fail "未知服务: $part" info "可用: $ORDER" info "别名: api git chat sync mail hook ui / backends / frontends" 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 t 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 t 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" } cmd_hostkey() { info "[hostkey] $(ensure_host_key)"; } # ---------- dispatch ---------- case "${1:-status}" in check) cmd_check ;; infra) cmd_infra up ;; infra-down) cmd_infra down ;; hostkey) cmd_hostkey ;; 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/hostkey/migrate/up/down/restart/status/logs/psql/redis' exit 1 ;; esac