Skip to content
Clone
39d7e6b
775 lines 23.4 KB Bourne Again Shell (bash)
#!/usr/bin/env bash
# GitData 本地开发编排脚本(macOS / Linux)。
# 由 justfile 调用:just <cmd> [svc...] -> scripts/dev.sh <cmd> [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 adminhost webhook aihost mcp web"
BACKENDS="apihost githost im synchost mails adminhost webhook aihost mcp"
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 ;;
adminhost) echo 3004 ;;
webhook) echo 3005 ;;
aihost) echo 3006 ;;
mcp) echo 3007 ;;
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 ;;
ai) echo aihost ;;
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" ;;
adminhost) echo "http://127.0.0.1:3004" ;;
webhook) echo "http://127.0.0.1:3005" ;;
aihost) echo "http://127.0.0.1:3006/healthz" ;;
mcp) echo "http://127.0.0.1:3007/healthz" ;;
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" </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"
}
# 前端依赖自检:
# 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 ai 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