#!/bin/bash # 稳定版 —— 把**当前代码**构建成一份快照,在另一组端口上常驻,给外部访问。 # 与 ./start.sh 起的开发服务(:8787 / :5173,tsup --watch + vite HMR)互不相干: # 开发那边改代码、重启、崩掉,这边一个字都不动;要发新版,显式跑一次 update。 # # ./stable.sh 起(没有快照就先构建;已在跑的不动) # ./stable.sh update 重建快照 + 重启两个 —— 这才是「发一版」 # ./stable.sh update **从某个分支/标签/commit 发版**(例:update backup/20260828)。 # 记住这个 ref,以后不带参数的 update 继续跟它走;回到当前工作树:update local # ./stable.sh restart 不重建,只重启 # ./stable.sh stop 停掉两个 # ./stable.sh status 只看状态,一个进程都不动 # ./stable.sh logs [api|web] 看日志(tail -f) # ./stable.sh env-from-dev 把开发服务(:8787)进程里的环境变量抄成 .stable/env # # **给了 ref 就在独立 git worktree(.stable/src)里签出并构建 —— 你的工作树一个字不动**, # 想改什么改什么、切分支也随意;稳定版跑的是那个 ref 定死的代码。 # worktree 一律 detached 签出:那个分支仍归你,别处照样 checkout 得动。 # lockfile 与主仓库一致时 `pnpm install` 是秒级的(全从同一个 store 硬链)。 # # 落盘位置(全部 gitignore): # apps/api/.build/stable/ 后端快照(tsup 一次性构建,不 watch)—— 必须住在 apps/api 下, # 产物里 `import "hono"` 这类裸名要从 apps/api/node_modules 解析 # apps/web/.build/stable/ 前端快照(vite build),由 vite preview 托静态 + /api 反代到稳定版后端 # (跟 ref 发版时,这两处在 .stable/src/ 里那一份) # .stable/src/ 跟 ref 发版用的独立 worktree(detached) # .stable/REF 现在跟着哪个 ref;`local` = 当前工作树 # .stable/env 环境变量(KEY=VALUE,600 权限)。缺它起不来 —— Keycloak 三样是硬门 # .stable/logs/ api.log / web.log / build.log # .stable/RELEASE 这份快照是哪个 ref / commit、什么时候构的 # # .stable/env 长这样(env-from-dev 会自动照开发进程抄一份;要单独一套自己改): # KEYCLOAK_URL=https://… KEYCLOAK_REALM=corp KEYCLOAK_CLIENT_ID=task-dispatcher # KEYCLOAK_PROVISIONING_CLIENT_ID=… KEYCLOAK_PROVISIONING_CLIENT_SECRET=…(可选,名册同步) # JCAI_TEST_ACCOUNTS=… JCAI_SECRET_KEY=…(开发没设的话这里也别设 —— 库里的密文 # 是用哪把钥匙封的就得用哪把开) # STABLE_DATABASE_URL=… 稳定版自己的库(不填 = 与开发共用一个,见下)。 # **注意名字带 STABLE_ 前缀**:这个文件是给人 source 的, # 写成通用的 DATABASE_URL 会让 source 过它的人误把 # activities:install / db:migrate / reset-runs 打到稳定库上(踩过) # STABLE_ATTACHMENT_ROOT=… 稳定版自己的附件根(同上,别写成 JCAI_ATTACHMENT_ROOT) # STABLE_API_PORT=8790 STABLE_WEB_PORT=5180 # STABLE_ALLOWED_HOSTS=jcai-bpos.akhr-ai.com 经域名/反代访问时要列在这儿(逗号分隔,`*` 全放)—— # vite preview 有宿主校验,不列会 403「This host is not allowed」 # # ./stable.sh stop web / restart api —— 第二个参数可只动一个 # # 关于数据库:默认与开发共用同一个库(两台后端连一个库是本仓库既有的跑法,跨进程租约与 # LISTEN/NOTIFY 桥都为此做过)。代价要知道:开发那边 `reset-runs` 会把稳定版上外部人跑出来的 # 任务一起清掉;开发那边跑了新迁移,稳定版的旧代码就在改过的表结构上跑。真要隔离, # 在 .stable/env 里把 DATABASE_URL 指到另一个库,并自己跑一遍迁移 + 播种。 # # 沿用 start.sh 踩过的坑:端口属主只认 LISTEN(不然会把浏览器的连接当成占用者); # 起完轮询到 /api/health 的 body 里 db:"up" 才算数(它在库挂了时照样返 200)。 set -u cd "$(dirname "$0")" || exit 1 REPO=$(pwd) STABLE="$REPO/.stable" ENV_FILE="$STABLE/env" LOGDIR="$STABLE/logs" REF_FILE="$STABLE/REF" WORKTREE="$STABLE/src" DEV_API_PORT=8787 mkdir -p "$LOGDIR" WHAT=${1:-start} say() { printf '%s\n' "$*"; } die() { say " ✗ $*"; exit 1; } # ── 源码从哪儿来 ──────────────────────────────────────────────────────── # `local` = 当前工作树(默认,老行为);别的 = .stable/src 那个 detached worktree。 # 记在 .stable/REF 里:不带参数的 update / restart 继续跟着它,不会悄悄跳回工作树 —— # 「稳定版跑的是哪份代码」这件事,得由上一次显式的决定说了算。 REF=$(cat "$REF_FILE" 2>/dev/null || echo local) use_source() { if [ "$REF" = 'local' ]; then SRC="$REPO" else SRC="$WORKTREE" fi API_DIR="$SRC/apps/api" WEB_DIR="$SRC/apps/web" API_OUT="$API_DIR/.build/stable" WEB_OUT="$WEB_DIR/.build/stable" } use_source # ── 环境 ──────────────────────────────────────────────────────────────── ENV_KEYS='KEYCLOAK_URL KEYCLOAK_REALM KEYCLOAK_CLIENT_ID KEYCLOAK_PROVISIONING_CLIENT_ID KEYCLOAK_PROVISIONING_CLIENT_SECRET JCAI_TEST_ACCOUNTS JCAI_SECRET_KEY JCAI_DEEPSEEK_API_KEY JCAI_LLM_API_KEY JCAI_LLM_BASE_URL DATABASE_URL JCAI_ATTACHMENT_ROOT JCAI_DISPATCH_INTERVAL_MS JCAI_MAIL_POLL_TICK_MS NODE_ENV' # 端口的真属主 —— 只认 LISTEN owner_of() { lsof -ti:"$1" -sTCP:LISTEN 2>/dev/null | head -1; } env_from_dev() { local pid envs k v n=0 pid=$(owner_of "$DEV_API_PORT") [ -n "$pid" ] || die "开发后端(:$DEV_API_PORT)没在跑,抄不到环境 —— 先 ./start.sh api,或照文件头的模版手写 $ENV_FILE" # macOS 的 ps -E 把环境变量追在命令后面,空格分隔;这几个键的值都不含空格 envs=$(ps -E -p "$pid" -o command= 2>/dev/null | tr ' ' '\n') [ -n "$envs" ] || die "读不到 pid $pid 的环境(不是同一个用户起的?)" : > "$ENV_FILE" chmod 600 "$ENV_FILE" for k in $ENV_KEYS; do v=$(printf '%s\n' "$envs" | grep -m1 "^$k=" | cut -d= -f2-) if [ -n "$v" ]; then printf '%s=%s\n' "$k" "$v" >> "$ENV_FILE" n=$((n + 1)) fi done printf '%s=%s\n%s=%s\n%s=%s\n' STABLE_API_PORT "${STABLE_API_PORT:-8790}" STABLE_WEB_PORT "${STABLE_WEB_PORT:-5180}" \ STABLE_ALLOWED_HOSTS "${STABLE_ALLOWED_HOSTS:-}" >> "$ENV_FILE" say " ✓ 从开发后端(pid $pid)抄了 $n 个变量 → $ENV_FILE(600)" say " $(cut -d= -f1 "$ENV_FILE" | tr '\n' ' ')" } load_env() { if [ ! -f "$ENV_FILE" ]; then say " $ENV_FILE 还没有,先照开发后端抄一份:" env_from_dev fi set -a # shellcheck disable=SC1090 . "$ENV_FILE" set +a API_PORT=${STABLE_API_PORT:-8790} WEB_PORT=${STABLE_WEB_PORT:-5180} # 库与附件根**只在这里**翻成通用名,而且只翻给马上要 exec 的那个进程。 # # 这条是踩出来的:头一版直接把 `DATABASE_URL=…jcai_stable` 写进 .stable/env, # 而这个文件是设计成给人 `source` 的(应急起后端、抄环境都这么干)—— # 于是任何人 source 完再跑 `pnpm activities:install` / `db:migrate` / `reset-runs`, # 就**毫无察觉地打在稳定库上**。实测发生过一次:2026-08-28 15:32:59 # 一个新活动包版本被装进了本该冻住的稳定库(15:30 刚装过开发库,2 分半后又中一枪)。 # **通用变量名放在会被 source 的文件里,等于给每个 source 它的人都上了一发膛**。 export DATABASE_URL=${STABLE_DATABASE_URL:-${DATABASE_URL:-}} export JCAI_ATTACHMENT_ROOT=${STABLE_ATTACHMENT_ROOT:-${JCAI_ATTACHMENT_ROOT:-}} [ -z "$DATABASE_URL" ] && unset DATABASE_URL [ -z "$JCAI_ATTACHMENT_ROOT" ] && unset JCAI_ATTACHMENT_ROOT for k in KEYCLOAK_URL KEYCLOAK_REALM KEYCLOAK_CLIENT_ID; do eval "v=\${$k:-}" # shellcheck disable=SC2154 [ -n "$v" ] || die "$ENV_FILE 里缺 $k —— 统一鉴权中心是唯一的登录途径,缺了后端拒绝启动(ADR-012)" done } # ── 冻库:不许把活动包装进稳定库 ──────────────────────────────────────── # # 为什么是**触发器**而不是权限:开发用的 `jcai` 是**超级用户**,REVOKE 对它无效 # (超级用户绕过一切权限检查)。触发器是数据逻辑,超级用户照样被 RAISE EXCEPTION 拦下 —— 实测过。 # # 冻的是「代码与配置」那几张表(装活动包 / 装工具 / 播种会写它们), # **不冻业务表** —— 稳定版自己还要写任务、台账、事件,冻了它就跑不动了。 # # 起因:2026-08-28 15:32:59 一个新活动包被装进了本该冻住的稳定库。 # 而且 `install-activities` 的失败路径是「打印一行然后 continue,永远 exit 0」—— # 拦下 INSERT 它也不会停,还会接着写别的表,所以**得把这一族一起冻上**。 # **只冻 INSERT**(= 装进来一个新东西),不冻 UPDATE —— # 运行时会更新这些表的状态(触发器的上次触发、模型的可用性…),治理台上启停也是 UPDATE; # 冻了 UPDATE 稳定版自己就跑不动了。要防的是「装新版本」,那正是 INSERT。 FREEZE_TABLES='sys.activity_package sys.activity_asset sys.tool sys.tool_allowlist sys.llm_instance sys.process_package sys."trigger"' db_url_of() { printf '%s' "${STABLE_DATABASE_URL:-}"; } # 在容器里对稳定库跑一段 SQL(共用开发那个 PG 实例,只是库不同) stable_psql() { local db db=$(printf '%s' "$(db_url_of)" | sed 's#.*/##') [ -n "$db" ] || die "没有 STABLE_DATABASE_URL —— 稳定版还在跟开发共用一个库,冻不了" docker exec -i jcai-postgres psql -U jcai -d "$db" -v ON_ERROR_STOP=1 } db_freeze() { local t n=0 say "冻结稳定库(不许装活动包 / 工具 / 播种):" { cat <<'SQL' CREATE OR REPLACE FUNCTION sys.frozen_guard() RETURNS trigger AS $fn$ BEGIN RAISE EXCEPTION '稳定库已冻结:不许对 %.% 做 %。装活动包 / 播种请对开发库做;确实要动稳定库,先 ./stable.sh db-unfreeze', TG_TABLE_SCHEMA, TG_TABLE_NAME, TG_OP USING HINT = '这道墙是触发器不是权限 —— 超级用户同样拦'; END $fn$ LANGUAGE plpgsql; SQL for t in $FREEZE_TABLES; do printf 'DROP TRIGGER IF EXISTS zz_frozen ON %s;\n' "$t" printf 'CREATE TRIGGER zz_frozen BEFORE INSERT ON %s FOR EACH ROW EXECUTE FUNCTION sys.frozen_guard();\n' "$t" done } | stable_psql > "$LOGDIR/db-freeze.log" 2>&1 || { # 表不存在之类的照说,但别把整件事说成成功 grep -iE 'error|不存在' "$LOGDIR/db-freeze.log" | head -3 say ' ! 有语句没跑成(全文 .stable/logs/db-freeze.log)' } # 数一遍再报 —— stable_psql 是交互式格式,这里单开一条 -tA 的连接现数 n=$(docker exec jcai-postgres psql -U jcai -d "$(printf '%s' "$(db_url_of)" | sed 's#.*/##')" \ -tAc "select count(*) from pg_trigger where tgname='zz_frozen'" 2>/dev/null | tr -d ' \n') say " ✓ 已冻 ${n:-?} 张表(只拦 INSERT):$FREEZE_TABLES" } db_unfreeze() { local t say '解冻稳定库(记得办完再 ./stable.sh db-freeze 冻回去):' for t in $FREEZE_TABLES; do printf 'DROP TRIGGER IF EXISTS zz_frozen ON %s;\n' "$t"; done | stable_psql > "$LOGDIR/db-freeze.log" 2>&1 say ' ✓ 已解冻 —— 现在装得进去了,**这扇门开着的时候开发侧的 install 会打中它**' } # ── 跟 ref 发版:独立 worktree ────────────────────────────────────────── # detached 签出 —— 那个分支仍归你,别处照样 checkout 得动(worktree 占着分支会拦下别人)。 prepare_worktree() { local target=$1 sha sha=$(git -C "$REPO" rev-parse --short "$target^{commit}" 2>/dev/null) \ || die "认不出这个 ref:$target(分支/标签/commit 都试过了)" if [ -d "$WORKTREE/.git" ] || [ -f "$WORKTREE/.git" ]; then say " worktree .stable/src → $target ($sha)" git -C "$WORKTREE" checkout --detach --force "$sha" > "$LOGDIR/build.log" 2>&1 \ || { tail -10 "$LOGDIR/build.log"; die 'worktree 切不过去(全文 .stable/logs/build.log)'; } else say " 新建 worktree .stable/src → $target ($sha)" rm -rf "$WORKTREE" git -C "$REPO" worktree add --detach "$WORKTREE" "$sha" > "$LOGDIR/build.log" 2>&1 \ || { tail -10 "$LOGDIR/build.log"; die 'worktree 建不起来(全文 .stable/logs/build.log)'; } fi # 依赖装在 worktree 自己的 node_modules 里。lockfile 与主仓库一致时是秒级(同一个 store 硬链); # 不加 --ignore-scripts:esbuild 的二进制要靠 postinstall 落地,少了它 tsup/vite 一步都跑不动 say ' pnpm install(worktree)…' ( cd "$WORKTREE" && pnpm install --frozen-lockfile ) >> "$LOGDIR/build.log" 2>&1 \ || { tail -20 "$LOGDIR/build.log"; die 'worktree 装依赖失败(全文 .stable/logs/build.log)'; } } # 这份快照到底是哪份代码 —— ref、commit、脏没脏,一行说清 snapshot_id() { local sha dirty sha=$(git -C "$SRC" rev-parse --short HEAD 2>/dev/null || echo unknown) if [ "$REF" = 'local' ]; then dirty=$([ -n "$(git -C "$SRC" status --porcelain 2>/dev/null)" ] && echo '+未提交改动' || echo '') printf 'local %s%s' "$sha" "$dirty" else printf '%s %s' "$REF" "$sha" fi } # ── 构建快照 ──────────────────────────────────────────────────────────── build() { local id at where where=$([ "$REF" = 'local' ] && echo 'apps' || echo '.stable/src/apps') say '构建快照:' say " api → $where/api/.build/stable …" # --out-dir 盖过 tsup.config 的 outDir;config 里的 clean:true 清的就是这个目录,碰不到开发的 dist/ # >> 而不是 > :跟 ref 发版时,前面 worktree 那几步的日志还要留着 if ! (cd "$API_DIR" && pnpm exec tsup --out-dir .build/stable) >> "$LOGDIR/build.log" 2>&1; then tail -20 "$LOGDIR/build.log" die 'api 构建失败(全文 .stable/logs/build.log)' fi [ -f "$API_OUT/main.js" ] || die "构建说成功,可 $API_OUT/main.js 不在" say " web → $where/web/.build/stable …" if ! (cd "$WEB_DIR" && pnpm exec vite build --outDir .build/stable --emptyOutDir) >> "$LOGDIR/build.log" 2>&1; then tail -20 "$LOGDIR/build.log" die 'web 构建失败(全文 .stable/logs/build.log)' fi [ -f "$WEB_OUT/index.html" ] || die "构建说成功,可 $WEB_OUT/index.html 不在" id=$(snapshot_id) at=$(date '+%Y-%m-%d %H:%M:%S') printf 'source=%s\nbuilt_at=%s\n' "$id" "$at" > "$STABLE/RELEASE" say " ✓ 快照:$id · $at" } have_snapshot() { [ -f "$API_OUT/main.js" ] && [ -f "$WEB_OUT/index.html" ]; } # ── 起停 ──────────────────────────────────────────────────────────────── # 直接 exec node,进程链上不留 pnpm/bash:pid 文件里记的就是真正听端口的那个进程 start_api() { if [ -n "$(owner_of "$API_PORT")" ]; then say " api :$API_PORT 已在跑,不动它" return 0 fi # cwd 必须是 apps/api:附件根目录默认 cwd/.data/attachments,与开发服务同一处 —— # 共用一个库时,库里存的 file:// 指针两边都得指得到 ( cd "$API_DIR" && PORT="$API_PORT" exec nohup node .build/stable/main.js < /dev/null >> "$LOGDIR/api.log" 2>&1 ) & echo $! > "$STABLE/api.pid" disown 2>/dev/null say " api :$API_PORT 启动中… (pid $(cat "$STABLE/api.pid"),日志 .stable/logs/api.log)" } start_web() { if [ -n "$(owner_of "$WEB_PORT")" ]; then say " web :$WEB_PORT 已在跑,不动它" return 0 fi # vite preview:托 .build/stable 的静态文件,/api 反代沿用 vite.config 的 server.proxy(preview 默认继承), # 目标由 API_PROXY_TARGET 指到稳定版后端;--host 绑全部网卡,外部才连得进来 ( cd "$WEB_DIR" && API_PROXY_TARGET="http://127.0.0.1:$API_PORT" PREVIEW_ALLOWED_HOSTS="${STABLE_ALLOWED_HOSTS:-}" \ exec nohup node node_modules/vite/bin/vite.js preview \ --outDir .build/stable --host --port "$WEB_PORT" --strictPort < /dev/null >> "$LOGDIR/web.log" 2>&1 ) & echo $! > "$STABLE/web.pid" disown 2>/dev/null say " web :$WEB_PORT 启动中… (pid $(cat "$STABLE/web.pid"),日志 .stable/logs/web.log)" } # 只杀「确实是我们起的那个」:pid 文件里的 pid 还活着且命令里带 .build/stable,或端口属主命令里带 .build/stable # (dist-stable 是 08-26 之前的旧落点,留着认一阵子,免得换目录那一刻停不掉老进程) is_ours() { ps -o command= -p "$1" 2>/dev/null | grep -qE '\.build/stable|dist-stable'; } stop_one() { local label=$1 port=$2 pidfile=$3 pid owner i pid=$(cat "$pidfile" 2>/dev/null || true) owner=$(owner_of "$port") if [ -z "$owner" ] && { [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; }; then say " $label 本来就没在跑" rm -f "$pidfile" return 0 fi # pid 文件里的与端口属主多半是同一个进程,别对它说两遍话 [ "$owner" = "$pid" ] && owner='' for i in $pid $owner; do if is_ours "$i"; then kill "$i" 2>/dev/null elif [ -n "$(ps -o command= -p "$i" 2>/dev/null)" ]; then say " ! :$port 的属主 pid $i 不是稳定版的进程,不动它:$(ps -o command= -p "$i" | cut -c1-80)" fi done for i in 1 2 3 4 5 6 7 8; do [ -z "$(owner_of "$port")" ] && { say " $label 已停"; rm -f "$pidfile"; return 0; } sleep 1 done owner=$(owner_of "$port") if is_ours "$owner"; then kill -9 "$owner" 2>/dev/null sleep 1 [ -z "$(owner_of "$port")" ] && { say " $label 已停(-9)"; rm -f "$pidfile"; return 0; } fi say " ! $label :$port 还被占着 —— 手工看看 lsof -i:$port" return 1 } # ── 核实 ──────────────────────────────────────────────────────────────── http_code() { curl -s -o /dev/null -m 3 -w '%{http_code}' "$1" 2>/dev/null; } # /api/health 在库挂了时照样返 200,得看 body 里的 db wait_api() { local i body for i in $(seq 1 45); do body=$(curl -s -m 3 "http://127.0.0.1:$API_PORT/api/health" 2>/dev/null) case "$body" in *'"db":"up"'*) say " ✓ api 就绪 (${i}s) $body"; return 0 ;; *'"db":"down"'*) say " ✗ api 起来了但连不上库:$body"; return 1 ;; esac # 进程已经死了就别等满 45 秒 if [ -f "$STABLE/api.pid" ] && ! kill -0 "$(cat "$STABLE/api.pid")" 2>/dev/null; then say ' ✗ api 进程已退出 —— 日志末尾:' tail -5 "$LOGDIR/api.log" | sed 's/^/ /' return 1 fi sleep 1 done say ' ✗ api 45 秒没有应答 —— 看 .stable/logs/api.log' return 1 } wait_web() { local i code for i in $(seq 1 30); do code=$(http_code "http://127.0.0.1:$WEB_PORT/") [ "$code" = '200' ] && { say " ✓ web 就绪 (${i}s)"; return 0; } if [ -f "$STABLE/web.pid" ] && ! kill -0 "$(cat "$STABLE/web.pid")" 2>/dev/null; then say ' ✗ web 进程已退出 —— 日志末尾:' tail -5 "$LOGDIR/web.log" | sed 's/^/ /' return 1 fi sleep 1 done say ' ✗ web 30 秒没有应答 —— 看 .stable/logs/web.log' return 1 } verify() { local rc=0 proxy spa wait_api || rc=1 wait_web || rc=1 proxy=$(http_code "http://127.0.0.1:$WEB_PORT/api/health") [ "$proxy" = '200' ] && say ' ✓ 反代 /api → 200' || { say " ✗ 反代 /api → ${proxy:-连不上}"; rc=1; } # 三个台面是同一个 SPA,刷新 /design /ops 要吐 index.html spa=$(http_code "http://127.0.0.1:$WEB_PORT/design") [ "$spa" = '200' ] && say ' ✓ SPA 回退 /design → 200' || { say " ✗ SPA 回退 /design → ${spa:-连不上}"; rc=1; } return $rc } lan_ip() { ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || true; } # 登录能不能跳回来,Keycloak 那一侧说了算:web-portal 客户端没登记这个来源的 redirect URI, # 页面开得了、点「前往统一登录」就吃一句 Invalid parameter: redirect_uri。 # 服务起好了不等于能用 —— 这里替人先问一次 Keycloak(只看它对 redirect_uri 的裁决,不登录)。 WEB_CLIENT_ID=$(grep -m1 '^VITE_KEYCLOAK_CLIENT_ID=' "$WEB_DIR/.env.production" 2>/dev/null | cut -d= -f2-) kc_redirect_ok() { local origin=$1 enc body enc=$(printf '%s/' "$origin" | sed 's,/,%2F,g; s,:,%3A,g') body=$(curl -s -m 10 "$KEYCLOAK_URL/realms/$KEYCLOAK_REALM/protocol/openid-connect/auth?client_id=${WEB_CLIENT_ID:-web-portal}&response_type=code&scope=openid&redirect_uri=$enc&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256" 2>/dev/null) case "$body" in '') return 2 ;; # Keycloak 连不上,说不出 *'Invalid parameter: redirect_uri'*) return 1 ;; # 明确拒了 *) return 0 ;; esac } # 列进 STABLE_ALLOWED_HOSTS 的域名,外部多半是 https 进来的,一并问 Keycloak(通配与整子域问不了,跳过) allowed_origins() { local h for h in $(printf '%s' "${STABLE_ALLOWED_HOSTS:-}" | tr ',' ' '); do case "$h" in '*' | .*) ;; *) printf 'https://%s\n' "$h" ;; esac done } report_kc() { local origin set -- "$@" $(allowed_origins) for origin in "$@"; do kc_redirect_ok "$origin" case $? in 0) say " ✓ Keycloak 接受 $origin 的 redirect_uri" ;; 1) say " ✗ Keycloak 拒绝 $origin 的 redirect_uri —— 去 realm $KEYCLOAK_REALM 的客户端 ${WEB_CLIENT_ID:-web-portal} 把 $origin/* 加进 Valid redirect URIs 与 Web origins,不然这个入口登不进" ;; *) say " ? Keycloak($KEYCLOAK_URL)没应答,登录能不能跳回来说不准" ;; esac done } finish() { local ip say '' if [ "$1" = '0' ]; then ip=$(lan_ip) say " 稳定版都好了 → http://localhost:$WEB_PORT/" [ -n "$ip" ] && say " 局域网 → http://$ip:$WEB_PORT/" report_kc "http://localhost:$WEB_PORT" ${ip:+"http://$ip:$WEB_PORT"} else say ' 有起不来的,日志在 .stable/logs/' fi } status() { local ip say '稳定版状态:' say " 跟着 $REF$([ "$REF" = 'local' ] && echo '(当前工作树)' || echo "(独立 worktree .stable/src,你的工作树随便改)")" [ -f "$STABLE/RELEASE" ] && sed 's/^/ 快照 /' "$STABLE/RELEASE" || say ' 快照 (还没构建过)' say " api :$API_PORT $(http_code "http://127.0.0.1:$API_PORT/api/health") $(curl -s -m 3 "http://127.0.0.1:$API_PORT/api/health" 2>/dev/null) pid $(owner_of "$API_PORT")" say " web :$WEB_PORT $(http_code "http://127.0.0.1:$WEB_PORT/") pid $(owner_of "$WEB_PORT")" say " 反代 /api $(http_code "http://127.0.0.1:$WEB_PORT/api/health")" ip=$(lan_ip) [ -n "$ip" ] && say " 入口 http://localhost:$WEB_PORT/ · http://$ip:$WEB_PORT/" report_kc "http://localhost:$WEB_PORT" ${ip:+"http://$ip:$WEB_PORT"} } # ── 分派 ──────────────────────────────────────────────────────────────── case "$WHAT" in env-from-dev) env_from_dev exit 0 ;; logs) exec tail -n 50 -f "$LOGDIR/${2:-api}.log" ;; status) load_env status exit 0 ;; db-freeze) load_env db_freeze exit 0 ;; db-unfreeze) load_env db_unfreeze exit 0 ;; stop) load_env say '停止稳定版:' [ "${2:-}" != 'web' ] && stop_one api "$API_PORT" "$STABLE/api.pid" [ "${2:-}" != 'api' ] && stop_one web "$WEB_PORT" "$STABLE/web.pid" exit 0 ;; start) load_env [ -n "${2:-}" ] && { REF=$2; use_source; printf '%s\n' "$REF" > "$REF_FILE"; } [ "$REF" != 'local' ] && [ ! -d "$WORKTREE/apps" ] && prepare_worktree "$REF" have_snapshot || build say "起稳定版(api :$API_PORT · web :$WEB_PORT · 源 $REF):" start_api start_web ;; update) load_env # 给了 ref 就换源并记住;没给就沿用 .stable/REF 上一次的决定 if [ -n "${2:-}" ]; then REF=$2 use_source printf '%s\n' "$REF" > "$REF_FILE" fi [ "$REF" != 'local' ] && prepare_worktree "$REF" build say '重启稳定版:' stop_one api "$API_PORT" "$STABLE/api.pid" stop_one web "$WEB_PORT" "$STABLE/web.pid" start_api start_web ;; restart) load_env have_snapshot || die '还没有快照,先 ./stable.sh update' say "重启稳定版${2:+($2)}:" [ "${2:-}" != 'web' ] && stop_one api "$API_PORT" "$STABLE/api.pid" [ "${2:-}" != 'api' ] && stop_one web "$WEB_PORT" "$STABLE/web.pid" start_api start_web ;; *) say "不认识的参数:$WHAT(可用:start / update [ref|local] / restart / stop / status / logs [api|web] / env-from-dev)" exit 2 ;; esac RC=0 verify || RC=1 finish $RC exit $RC