diff --git a/docs/superpowers/plans/2026-07-15-run-command.md b/docs/superpowers/plans/2026-07-15-run-command.md
new file mode 100644
index 0000000..c8fd64c
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-15-run-command.md
@@ -0,0 +1,516 @@
+# ERP One-Command Launcher Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a Finder-double-clickable `run.command` that safely starts or reuses the ERP Spring Boot service and fixed-domain ngrok tunnel, opens the preview, and owns cleanup in one Terminal window.
+
+**Architecture:** A Bash 3.2-compatible supervisor resolves all paths from its own location, exposes focused health/ownership functions for Shell tests, and executes `main` only when run directly. It records only child PIDs it creates, so signal cleanup cannot kill pre-existing Java or ngrok processes.
+
+**Tech Stack:** macOS Bash, `curl`, `lsof`, Python 3 JSON parsing, project-bundled Temurin 17, ngrok 3.
+
+---
+
+## File map
+
+- Create: `run.command` — user-facing launcher, readiness checks, process ownership, monitoring, and cleanup.
+- Create: `tests/run-command.test.sh` — dependency-free Shell regression suite that sources launcher functions in isolated subshells.
+- Existing reference only: `docs/superpowers/specs/2026-07-15-run-command-design.md` — approved behavioral contract.
+
+### Task 1: Establish source-safe launcher structure
+
+**Files:**
+- Create: `tests/run-command.test.sh`
+- Create: `run.command`
+
+- [x] **Step 1: Write the failing path-resolution test**
+
+Create the initial test runner with a test that sources the launcher from `/tmp` and verifies that it resolves the ERP root without starting services:
+
+```bash
+#!/bin/bash
+set -uo pipefail
+
+PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
+SCRIPT="$PROJECT_ROOT/run.command"
+PASS=0
+FAIL=0
+
+run_test() {
+ local name="$1"
+ shift
+ if ("$@"); then
+ printf 'PASS %s\n' "$name"
+ PASS=$((PASS + 1))
+ else
+ printf 'FAIL %s\n' "$name"
+ FAIL=$((FAIL + 1))
+ fi
+}
+
+test_resolves_root_when_sourced_elsewhere() (
+ [ -r "$SCRIPT" ] || return 1
+ cd /tmp || return 1
+ source "$SCRIPT"
+ [ "$ROOT_DIR" = "$PROJECT_ROOT" ]
+ [ -z "$BACKEND_PID" ]
+ [ -z "$NGROK_PID" ]
+)
+
+run_test 'resolves project root when sourced elsewhere' test_resolves_root_when_sourced_elsewhere
+printf 'RESULT pass=%s fail=%s\n' "$PASS" "$FAIL"
+[ "$FAIL" -eq 0 ]
+```
+
+- [x] **Step 2: Run the test and verify RED**
+
+Run:
+
+```bash
+bash tests/run-command.test.sh
+```
+
+Expected: exit non-zero with `FAIL resolves project root when sourced elsewhere` because `run.command` does not exist.
+
+- [x] **Step 3: Add the minimal source-safe launcher skeleton**
+
+Create `run.command` with project-relative constants, empty ownership state, and a direct-execution guard:
+
+```bash
+#!/bin/bash
+set -uo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
+ROOT_DIR="${ERP_RUN_ROOT_DIR:-$SCRIPT_DIR}"
+BACKEND_DIR="${ERP_RUN_BACKEND_DIR:-$ROOT_DIR/oa-backend}"
+JAVA_BIN="${ERP_RUN_JAVA_BIN:-$ROOT_DIR/.jdks/jdk-17.0.19+10/Contents/Home/bin/java}"
+JAR_PATH="${ERP_RUN_JAR_PATH:-$BACKEND_DIR/build/libs/oa-backend-0.1.0.jar}"
+BACKEND_PORT="${ERP_RUN_BACKEND_PORT:-8091}"
+NGROK_API_PORT="${ERP_RUN_NGROK_API_PORT:-4040}"
+LOCAL_URL="http://127.0.0.1:$BACKEND_PORT"
+PUBLIC_URL="${ERP_RUN_PUBLIC_URL:-https://resonant-elated-launder.ngrok-free.dev}"
+NGROK_TARGET="http://localhost:$BACKEND_PORT"
+LOG_DIR="${ERP_RUN_LOG_DIR:-${TMPDIR:-/tmp}/kaidi-erp-run}"
+BACKEND_LOG="$LOG_DIR/backend.log"
+NGROK_LOG="$LOG_DIR/ngrok.log"
+BACKEND_PID=""
+NGROK_PID=""
+CLEANED_UP=0
+
+main() {
+ return 0
+}
+
+if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
+ main "$@"
+fi
+```
+
+- [x] **Step 4: Run syntax and source tests and verify GREEN**
+
+Run:
+
+```bash
+bash -n run.command
+bash tests/run-command.test.sh
+```
+
+Expected: both commands exit 0 and the test reports `RESULT pass=1 fail=0`.
+
+### Task 2: Add safe backend reuse and owned-process cleanup
+
+**Files:**
+- Modify: `tests/run-command.test.sh`
+- Modify: `run.command`
+
+- [x] **Step 1: Add failing backend and cleanup tests**
+
+Insert these tests before the runner calls, then add their `run_test` calls:
+
+```bash
+test_reuses_healthy_backend() (
+ source "$SCRIPT"
+ backend_is_healthy() { return 0; }
+ port_listener_pid() { printf '4242\n'; }
+ start_backend() { return 77; }
+ ensure_backend
+ [ -z "$BACKEND_PID" ]
+)
+
+test_rejects_unhealthy_port_without_killing_owner() (
+ source "$SCRIPT"
+ sleep 30 &
+ local external_pid=$!
+ trap 'kill "$external_pid" 2>/dev/null || true' EXIT
+ backend_is_healthy() { return 1; }
+ port_listener_pid() { printf '%s\n' "$external_pid"; }
+ if ensure_backend; then return 1; fi
+ kill -0 "$external_pid" 2>/dev/null
+)
+
+test_cleanup_stops_owned_pid_only() (
+ source "$SCRIPT"
+ sleep 30 &
+ BACKEND_PID=$!
+ sleep 30 &
+ local external_pid=$!
+ cleanup
+ if kill -0 "$BACKEND_PID" 2>/dev/null; then return 1; fi
+ kill -0 "$external_pid" 2>/dev/null || return 1
+ kill "$external_pid" 2>/dev/null || true
+)
+
+run_test 'reuses a healthy backend' test_reuses_healthy_backend
+run_test 'rejects a foreign 8091 listener without killing it' test_rejects_unhealthy_port_without_killing_owner
+run_test 'cleanup stops owned PID only' test_cleanup_stops_owned_pid_only
+```
+
+- [x] **Step 2: Run the tests and verify RED**
+
+Run `bash tests/run-command.test.sh`.
+
+Expected: the original path test passes; new tests fail because `ensure_backend` and `cleanup` are undefined.
+
+- [x] **Step 3: Implement minimal backend and ownership functions**
+
+Add these functions above `main` in `run.command`:
+
+```bash
+say() { printf '[ERP] %s\n' "$*"; }
+fail() { printf '[ERP] ERROR: %s\n' "$*" >&2; return 1; }
+
+port_listener_pid() {
+ lsof -nP -iTCP:"$1" -sTCP:LISTEN -t 2>/dev/null | head -n 1
+}
+
+backend_is_healthy() {
+ local body
+ body="$(curl -fsS --connect-timeout 1 --max-time 3 "$LOCAL_URL/" 2>/dev/null)" || return 1
+ [[ "$body" == *'
凯迪协同办公平台'* ]]
+}
+
+login_is_healthy() {
+ curl -fsS --connect-timeout 1 --max-time 5 \
+ -H 'Content-Type: application/json' \
+ -d '{"loginName":"admin","password":"123456"}' \
+ "$LOCAL_URL/api/oa/auth/login" 2>/dev/null \
+ | python3 -c 'import json,sys; d=json.load(sys.stdin); raise SystemExit(0 if d.get("code")==0 and d.get("data",{}).get("token") else 1)'
+}
+
+start_backend() {
+ : > "$BACKEND_LOG"
+ (
+ cd "$BACKEND_DIR" || exit 1
+ exec "$JAVA_BIN" -jar "$JAR_PATH" --server.port="$BACKEND_PORT"
+ ) >> "$BACKEND_LOG" 2>&1 &
+ BACKEND_PID=$!
+}
+
+wait_for_backend() {
+ local deadline=$((SECONDS + 60))
+ while [ "$SECONDS" -lt "$deadline" ]; do
+ if backend_is_healthy && login_is_healthy; then return 0; fi
+ if [ -n "$BACKEND_PID" ] && ! kill -0 "$BACKEND_PID" 2>/dev/null; then return 1; fi
+ sleep 1
+ done
+ return 1
+}
+
+ensure_backend() {
+ local listener
+ listener="$(port_listener_pid "$BACKEND_PORT" || true)"
+ if [ -n "$listener" ]; then
+ if ! backend_is_healthy; then
+ fail "端口 $BACKEND_PORT 已被非本项目服务占用(PID $listener)"
+ return 1
+ fi
+ say "复用已运行的 ERP 服务(PID $listener)"
+ return 0
+ fi
+ say "启动 ERP 服务..."
+ start_backend || return 1
+ if ! wait_for_backend; then
+ tail -n 40 "$BACKEND_LOG" >&2 || true
+ fail "ERP 服务未在 60 秒内就绪"
+ fi
+}
+
+stop_owned_process() {
+ local pid="$1"
+ [ -n "$pid" ] || return 0
+ kill -0 "$pid" 2>/dev/null || return 0
+ kill "$pid" 2>/dev/null || true
+ local attempt=0
+ while kill -0 "$pid" 2>/dev/null && [ "$attempt" -lt 5 ]; do
+ sleep 1
+ attempt=$((attempt + 1))
+ done
+ if kill -0 "$pid" 2>/dev/null; then kill -9 "$pid" 2>/dev/null || true; fi
+ wait "$pid" 2>/dev/null || true
+}
+
+cleanup() {
+ [ "$CLEANED_UP" -eq 0 ] || return 0
+ CLEANED_UP=1
+ stop_owned_process "$NGROK_PID"
+ stop_owned_process "$BACKEND_PID"
+}
+```
+
+- [x] **Step 4: Run the tests and verify GREEN**
+
+Run `bash tests/run-command.test.sh`.
+
+Expected: all four tests pass and no external test PID is terminated.
+
+### Task 3: Add ngrok, browser gating, orchestration, and monitoring
+
+**Files:**
+- Modify: `tests/run-command.test.sh`
+- Modify: `run.command`
+
+- [x] **Step 1: Add failing orchestration tests**
+
+Add these tests and runner calls:
+
+```bash
+test_reuses_healthy_ngrok() (
+ source "$SCRIPT"
+ ngrok_is_healthy() { return 0; }
+ port_listener_pid() { printf '5252\n'; }
+ start_ngrok() { return 78; }
+ ensure_ngrok
+ [ -z "$NGROK_PID" ]
+)
+
+test_no_open_mode_skips_browser() (
+ source "$SCRIPT"
+ local marker="${TMPDIR:-/tmp}/run-command-open-$$"
+ rm -f "$marker"
+ open() { : > "$marker"; }
+ ERP_RUN_NO_OPEN=1
+ maybe_open_browser
+ [ ! -e "$marker" ]
+)
+
+test_main_runs_steps_in_order() (
+ source "$SCRIPT"
+ local events="${TMPDIR:-/tmp}/run-command-events-$$"
+ : > "$events"
+ preflight() { printf 'preflight\n' >> "$events"; }
+ ensure_backend() { printf 'backend\n' >> "$events"; }
+ ensure_ngrok() { printf 'ngrok\n' >> "$events"; }
+ wait_for_public() { printf 'public\n' >> "$events"; }
+ maybe_open_browser() { printf 'open\n' >> "$events"; }
+ monitor_services() { printf 'monitor\n' >> "$events"; }
+ main
+ [ "$(tr '\n' ' ' < "$events")" = 'preflight backend ngrok public open monitor ' ]
+)
+
+run_test 'reuses a healthy ngrok tunnel' test_reuses_healthy_ngrok
+run_test 'no-open mode skips browser launch' test_no_open_mode_skips_browser
+run_test 'main orchestrates steps in order' test_main_runs_steps_in_order
+```
+
+- [x] **Step 2: Run the tests and verify RED**
+
+Run `bash tests/run-command.test.sh`.
+
+Expected: prior tests pass; the three new tests fail because ngrok/orchestration functions are not implemented.
+
+- [x] **Step 3: Implement preflight, ngrok, public verification, and main**
+
+Add the following functions above `main`, then replace the empty `main`:
+
+```bash
+resolve_ngrok_bin() {
+ if [ -n "${ERP_RUN_NGROK_BIN:-}" ]; then printf '%s\n' "$ERP_RUN_NGROK_BIN"; return; fi
+ if [ -x "$HOME/bin/ngrok" ]; then printf '%s\n' "$HOME/bin/ngrok"; return; fi
+ command -v ngrok 2>/dev/null || return 1
+}
+
+preflight() {
+ command -v curl >/dev/null || { fail '缺少 curl'; return 1; }
+ command -v lsof >/dev/null || { fail '缺少 lsof'; return 1; }
+ command -v python3 >/dev/null || { fail '缺少 python3'; return 1; }
+ [ -x "$JAVA_BIN" ] || { fail "找不到项目 JDK:$JAVA_BIN"; return 1; }
+ [ -r "$JAR_PATH" ] || { fail "找不到可运行 JAR:$JAR_PATH"; return 1; }
+ [ -r "$BACKEND_DIR/data/oa.db" ] || { fail "找不到 SQLite 数据库:$BACKEND_DIR/data/oa.db"; return 1; }
+ NGROK_BIN="$(resolve_ngrok_bin)" || { fail '找不到 ngrok(预期 $HOME/bin/ngrok 或 PATH)'; return 1; }
+ mkdir -p "$LOG_DIR" || { fail "无法创建日志目录:$LOG_DIR"; return 1; }
+}
+
+ngrok_is_healthy() {
+ local payload
+ payload="$(curl -fsS --connect-timeout 1 --max-time 3 "http://127.0.0.1:$NGROK_API_PORT/api/tunnels" 2>/dev/null)" || return 1
+ python3 -c 'import json,sys; d=json.load(sys.stdin); pub,target=sys.argv[1:3]; raise SystemExit(0 if any(t.get("public_url")==pub and t.get("config",{}).get("addr")==target for t in d.get("tunnels",[])) else 1)' \
+ "$PUBLIC_URL" "$NGROK_TARGET" <<< "$payload"
+}
+
+start_ngrok() {
+ : > "$NGROK_LOG"
+ "$NGROK_BIN" http --url=resonant-elated-launder.ngrok-free.dev "$BACKEND_PORT" \
+ --log=stdout --log-format=json >> "$NGROK_LOG" 2>&1 &
+ NGROK_PID=$!
+}
+
+wait_for_ngrok() {
+ local deadline=$((SECONDS + 30))
+ while [ "$SECONDS" -lt "$deadline" ]; do
+ ngrok_is_healthy && return 0
+ if [ -n "$NGROK_PID" ] && ! kill -0 "$NGROK_PID" 2>/dev/null; then return 1; fi
+ sleep 1
+ done
+ return 1
+}
+
+ensure_ngrok() {
+ if ngrok_is_healthy; then
+ say '复用已运行的 ngrok 隧道'
+ return 0
+ fi
+ local listener
+ listener="$(port_listener_pid "$NGROK_API_PORT" || true)"
+ if [ -n "$listener" ]; then
+ fail "端口 $NGROK_API_PORT 已有其他 ngrok/服务(PID $listener),但固定隧道不匹配"
+ return 1
+ fi
+ say '启动 ngrok...'
+ start_ngrok || return 1
+ if ! wait_for_ngrok; then
+ tail -n 40 "$NGROK_LOG" >&2 || true
+ fail 'ngrok 未在 30 秒内建立固定隧道'
+ fi
+}
+
+public_is_healthy() {
+ local body
+ body="$(curl -fsS --connect-timeout 3 --max-time 10 -H 'ngrok-skip-browser-warning: true' "$PUBLIC_URL/" 2>/dev/null)" || return 1
+ [[ "$body" == *'凯迪协同办公平台'* ]]
+}
+
+wait_for_public() {
+ local deadline=$((SECONDS + 30))
+ while [ "$SECONDS" -lt "$deadline" ]; do
+ public_is_healthy && return 0
+ sleep 1
+ done
+ fail '公网地址未在 30 秒内可访问'
+}
+
+maybe_open_browser() {
+ [ "${ERP_RUN_NO_OPEN:-0}" = '1' ] && return 0
+ command -v open >/dev/null || { fail '找不到 macOS open 命令'; return 1; }
+ open "$PUBLIC_URL"
+}
+
+monitor_services() {
+ local backend_active ngrok_active
+ backend_active="$(port_listener_pid "$BACKEND_PORT" || true)"
+ ngrok_active="$(port_listener_pid "$NGROK_API_PORT" || true)"
+ say "本地:$LOCAL_URL"
+ say "公网:$PUBLIC_URL"
+ say "进程:ERP PID ${backend_active:-未知},ngrok PID ${ngrok_active:-未知}"
+ say "日志:$BACKEND_LOG;$NGROK_LOG"
+ say '运行中;按 Ctrl+C 同时停止本次启动的服务。'
+ while :; do
+ if ! backend_is_healthy; then
+ [ -n "$BACKEND_PID" ] && tail -n 40 "$BACKEND_LOG" >&2 || true
+ fail 'ERP 服务失去响应'
+ return 1
+ fi
+ if ! ngrok_is_healthy; then
+ [ -n "$NGROK_PID" ] && tail -n 40 "$NGROK_LOG" >&2 || true
+ fail 'ngrok 隧道失去响应'
+ return 1
+ fi
+ sleep 5
+ done
+}
+
+handle_signal() {
+ printf '\n'
+ say '正在停止本次启动的服务...'
+ cleanup
+ exit 0
+}
+
+main() {
+ trap handle_signal HUP INT TERM
+ trap cleanup EXIT
+ say '检查运行环境...'
+ preflight || return 1
+ ensure_backend || return 1
+ ensure_ngrok || return 1
+ wait_for_public || return 1
+ maybe_open_browser || return 1
+ monitor_services
+}
+```
+
+- [x] **Step 4: Run the complete unit suite and verify GREEN**
+
+Run:
+
+```bash
+bash -n run.command
+bash -n tests/run-command.test.sh
+bash tests/run-command.test.sh
+```
+
+Expected: syntax checks exit 0; test suite reports seven passing tests and zero failures.
+
+### Task 4: Verify executable behavior against live services
+
+**Files:**
+- Modify mode: `run.command`
+- Modify mode: `tests/run-command.test.sh`
+
+- [x] **Step 1: Mark both scripts executable**
+
+Run:
+
+```bash
+chmod +x run.command tests/run-command.test.sh
+```
+
+- [x] **Step 2: Verify repeat-start safety while services already run**
+
+Run the launcher with browser opening disabled, wait for `运行中`, send `INT`, and verify the pre-existing Java/ngrok PIDs still listen:
+
+```bash
+ERP_RUN_NO_OPEN=1 ./run.command
+```
+
+Expected: output says both services are reused; after `Ctrl+C`, ports `8091` and `4040` remain listening.
+
+- [x] **Step 3: Verify cold start and owned cleanup**
+
+Stop only the known current ERP/ngrok sessions, run `ERP_RUN_NO_OPEN=1 ./run.command`, and verify local/public behavior:
+
+```bash
+curl -fsS http://127.0.0.1:8091/ | grep -F '凯迪协同办公平台'
+curl -fsS -H 'ngrok-skip-browser-warning: true' https://resonant-elated-launder.ngrok-free.dev/ | grep -F '凯迪协同办公平台'
+```
+
+Then post `admin/123456`, use the returned token for `/api/oa/dev-projects`, and require API `code=0`. Send `Ctrl+C` and verify both launcher-owned listeners disappear.
+
+- [x] **Step 4: Start the final user-facing instance**
+
+Run `./run.command` normally in a persistent terminal session.
+
+Expected: the public URL opens, both listeners remain active, and the terminal shows the stop instruction.
+
+- [x] **Step 5: Commit the implementation with Lore trailers**
+
+Stage only the two implementation files and this plan:
+
+```bash
+git add run.command tests/run-command.test.sh docs/superpowers/plans/2026-07-15-run-command.md
+git commit -m "Make local ERP previews one double-click away" -m "Constraint: Finder launch must supervise Java and ngrok in one visible Terminal
+Rejected: LaunchAgent | obscures ownership and makes safe cleanup harder
+Confidence: high
+Scope-risk: narrow
+Directive: Never kill listeners not recorded as launcher-owned PIDs
+Tested: Shell unit suite, repeat-start reuse, cold start, local/public login and protected API smoke
+Not-tested: Finder Gatekeeper behavior on a different Mac"
+```
diff --git a/run.command b/run.command
new file mode 100755
index 0000000..4fb927d
--- /dev/null
+++ b/run.command
@@ -0,0 +1,260 @@
+#!/bin/bash
+set -uo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
+ROOT_DIR="${ERP_RUN_ROOT_DIR:-$SCRIPT_DIR}"
+BACKEND_DIR="${ERP_RUN_BACKEND_DIR:-$ROOT_DIR/oa-backend}"
+JAVA_BIN="${ERP_RUN_JAVA_BIN:-$ROOT_DIR/.jdks/jdk-17.0.19+10/Contents/Home/bin/java}"
+JAR_PATH="${ERP_RUN_JAR_PATH:-$BACKEND_DIR/build/libs/oa-backend-0.1.0.jar}"
+BACKEND_PORT="${ERP_RUN_BACKEND_PORT:-8091}"
+NGROK_API_PORT="${ERP_RUN_NGROK_API_PORT:-4040}"
+LOCAL_URL="http://127.0.0.1:$BACKEND_PORT"
+PUBLIC_URL="${ERP_RUN_PUBLIC_URL:-https://resonant-elated-launder.ngrok-free.dev}"
+NGROK_TARGET="http://localhost:$BACKEND_PORT"
+LOG_DIR="${ERP_RUN_LOG_DIR:-${TMPDIR:-/tmp}/kaidi-erp-run}"
+BACKEND_LOG="$LOG_DIR/backend.log"
+NGROK_LOG="$LOG_DIR/ngrok.log"
+NGROK_BIN=""
+BACKEND_PID=""
+NGROK_PID=""
+CLEANED_UP=0
+
+say() {
+ printf '[ERP] %s\n' "$*"
+}
+
+fail() {
+ printf '[ERP] ERROR: %s\n' "$*" >&2
+ return 1
+}
+
+port_listener_pid() {
+ lsof -nP -iTCP:"$1" -sTCP:LISTEN -t 2>/dev/null | head -n 1
+}
+
+backend_is_healthy() {
+ local body
+ body="$(curl -fsS --connect-timeout 1 --max-time 3 "$LOCAL_URL/" 2>/dev/null)" || return 1
+ [[ "$body" == *'凯迪协同办公平台'* ]]
+}
+
+login_is_healthy() {
+ curl -fsS --connect-timeout 1 --max-time 5 \
+ -H 'Content-Type: application/json' \
+ -d '{"loginName":"admin","password":"123456"}' \
+ "$LOCAL_URL/api/oa/auth/login" 2>/dev/null \
+ | python3 -c 'import json,sys; d=json.load(sys.stdin); raise SystemExit(0 if d.get("code")==0 and d.get("data",{}).get("token") else 1)'
+}
+
+start_backend() {
+ : > "$BACKEND_LOG"
+ (
+ cd "$BACKEND_DIR" || exit 1
+ exec "$JAVA_BIN" -jar "$JAR_PATH" --server.port="$BACKEND_PORT"
+ ) >> "$BACKEND_LOG" 2>&1 &
+ BACKEND_PID=$!
+}
+
+wait_for_backend() {
+ local deadline=$((SECONDS + 60))
+ while [ "$SECONDS" -lt "$deadline" ]; do
+ if backend_is_healthy && login_is_healthy; then
+ return 0
+ fi
+ if [ -n "$BACKEND_PID" ] && ! kill -0 "$BACKEND_PID" 2>/dev/null; then
+ return 1
+ fi
+ sleep 1
+ done
+ return 1
+}
+
+ensure_backend() {
+ local listener
+ listener="$(port_listener_pid "$BACKEND_PORT" || true)"
+ if [ -n "$listener" ]; then
+ if ! backend_is_healthy; then
+ fail "端口 $BACKEND_PORT 已被非本项目服务占用(PID ${listener})"
+ return 1
+ fi
+ say "复用已运行的 ERP 服务(PID ${listener})"
+ return 0
+ fi
+
+ say '启动 ERP 服务...'
+ start_backend || return 1
+ if ! wait_for_backend; then
+ tail -n 40 "$BACKEND_LOG" >&2 || true
+ fail 'ERP 服务未在 60 秒内就绪'
+ return 1
+ fi
+}
+
+stop_owned_process() {
+ local pid="$1"
+ [ -n "$pid" ] || return 0
+ kill -0 "$pid" 2>/dev/null || return 0
+
+ kill "$pid" 2>/dev/null || true
+ local attempt=0
+ while kill -0 "$pid" 2>/dev/null && [ "$attempt" -lt 5 ]; do
+ sleep 1
+ attempt=$((attempt + 1))
+ done
+ if kill -0 "$pid" 2>/dev/null; then
+ kill -9 "$pid" 2>/dev/null || true
+ fi
+ wait "$pid" 2>/dev/null || true
+}
+
+cleanup() {
+ [ "$CLEANED_UP" -eq 0 ] || return 0
+ CLEANED_UP=1
+ stop_owned_process "$NGROK_PID"
+ stop_owned_process "$BACKEND_PID"
+}
+
+resolve_ngrok_bin() {
+ if [ -n "${ERP_RUN_NGROK_BIN:-}" ]; then
+ printf '%s\n' "$ERP_RUN_NGROK_BIN"
+ return 0
+ fi
+ if [ -x "$HOME/bin/ngrok" ]; then
+ printf '%s\n' "$HOME/bin/ngrok"
+ return 0
+ fi
+ command -v ngrok 2>/dev/null || return 1
+}
+
+preflight() {
+ command -v curl >/dev/null || { fail '缺少 curl'; return 1; }
+ command -v lsof >/dev/null || { fail '缺少 lsof'; return 1; }
+ command -v python3 >/dev/null || { fail '缺少 python3'; return 1; }
+ [ -x "$JAVA_BIN" ] || { fail "找不到项目 JDK:$JAVA_BIN"; return 1; }
+ [ -r "$JAR_PATH" ] || { fail "找不到可运行 JAR:$JAR_PATH"; return 1; }
+ [ -r "$BACKEND_DIR/data/oa.db" ] || { fail "找不到 SQLite 数据库:$BACKEND_DIR/data/oa.db"; return 1; }
+ NGROK_BIN="$(resolve_ngrok_bin)" || { fail '找不到 ngrok(预期 $HOME/bin/ngrok 或 PATH)'; return 1; }
+ mkdir -p "$LOG_DIR" || { fail "无法创建日志目录:$LOG_DIR"; return 1; }
+}
+
+ngrok_is_healthy() {
+ local payload
+ payload="$(curl -fsS --connect-timeout 1 --max-time 3 "http://127.0.0.1:$NGROK_API_PORT/api/tunnels" 2>/dev/null)" || return 1
+ python3 -c 'import json,sys; d=json.load(sys.stdin); pub,target=sys.argv[1:3]; raise SystemExit(0 if any(t.get("public_url")==pub and t.get("config",{}).get("addr")==target for t in d.get("tunnels",[])) else 1)' \
+ "$PUBLIC_URL" "$NGROK_TARGET" <<< "$payload"
+}
+
+start_ngrok() {
+ : > "$NGROK_LOG"
+ "$NGROK_BIN" http --url=resonant-elated-launder.ngrok-free.dev "$BACKEND_PORT" \
+ --log=stdout --log-format=json >> "$NGROK_LOG" 2>&1 &
+ NGROK_PID=$!
+}
+
+wait_for_ngrok() {
+ local deadline=$((SECONDS + 30))
+ while [ "$SECONDS" -lt "$deadline" ]; do
+ if ngrok_is_healthy; then
+ return 0
+ fi
+ if [ -n "$NGROK_PID" ] && ! kill -0 "$NGROK_PID" 2>/dev/null; then
+ return 1
+ fi
+ sleep 1
+ done
+ return 1
+}
+
+ensure_ngrok() {
+ if ngrok_is_healthy; then
+ say '复用已运行的 ngrok 隧道'
+ return 0
+ fi
+
+ local listener
+ listener="$(port_listener_pid "$NGROK_API_PORT" || true)"
+ if [ -n "$listener" ]; then
+ fail "端口 $NGROK_API_PORT 已有其他 ngrok/服务(PID ${listener}),但固定隧道不匹配"
+ return 1
+ fi
+
+ say '启动 ngrok...'
+ start_ngrok || return 1
+ if ! wait_for_ngrok; then
+ tail -n 40 "$NGROK_LOG" >&2 || true
+ fail 'ngrok 未在 30 秒内建立固定隧道'
+ return 1
+ fi
+}
+
+public_is_healthy() {
+ local body
+ body="$(curl -fsS --connect-timeout 3 --max-time 10 \
+ -H 'ngrok-skip-browser-warning: true' "$PUBLIC_URL/" 2>/dev/null)" || return 1
+ [[ "$body" == *'凯迪协同办公平台'* ]]
+}
+
+wait_for_public() {
+ local deadline=$((SECONDS + 30))
+ while [ "$SECONDS" -lt "$deadline" ]; do
+ if public_is_healthy; then
+ return 0
+ fi
+ sleep 1
+ done
+ fail '公网地址未在 30 秒内可访问'
+}
+
+maybe_open_browser() {
+ [ "${ERP_RUN_NO_OPEN:-0}" = '1' ] && return 0
+ command -v open >/dev/null || { fail '找不到 macOS open 命令'; return 1; }
+ open "$PUBLIC_URL"
+}
+
+monitor_services() {
+ local backend_active ngrok_active
+ backend_active="$(port_listener_pid "$BACKEND_PORT" || true)"
+ ngrok_active="$(port_listener_pid "$NGROK_API_PORT" || true)"
+ say "本地:$LOCAL_URL"
+ say "公网:$PUBLIC_URL"
+ say "进程:ERP PID ${backend_active:-未知},ngrok PID ${ngrok_active:-未知}"
+ say "日志:${BACKEND_LOG};${NGROK_LOG}"
+ say '运行中;按 Ctrl+C 同时停止本次启动的服务。'
+
+ while :; do
+ if ! backend_is_healthy; then
+ [ -n "$BACKEND_PID" ] && tail -n 40 "$BACKEND_LOG" >&2 || true
+ fail 'ERP 服务失去响应'
+ return 1
+ fi
+ if ! ngrok_is_healthy; then
+ [ -n "$NGROK_PID" ] && tail -n 40 "$NGROK_LOG" >&2 || true
+ fail 'ngrok 隧道失去响应'
+ return 1
+ fi
+ sleep 5
+ done
+}
+
+handle_signal() {
+ printf '\n'
+ say '正在停止本次启动的服务...'
+ cleanup
+ exit 0
+}
+
+main() {
+ trap handle_signal HUP INT TERM
+ trap cleanup EXIT
+ say '检查运行环境...'
+ preflight || return 1
+ ensure_backend || return 1
+ ensure_ngrok || return 1
+ wait_for_public || return 1
+ maybe_open_browser || return 1
+ monitor_services
+}
+
+if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
+ main "$@"
+fi
diff --git a/tests/run-command.test.sh b/tests/run-command.test.sh
new file mode 100755
index 0000000..5038bb5
--- /dev/null
+++ b/tests/run-command.test.sh
@@ -0,0 +1,121 @@
+#!/bin/bash
+set -uo pipefail
+
+PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
+SCRIPT="$PROJECT_ROOT/run.command"
+PASS=0
+FAIL=0
+
+run_test() {
+ local name="$1"
+ shift
+ if ("$@"); then
+ printf 'PASS %s\n' "$name"
+ PASS=$((PASS + 1))
+ else
+ printf 'FAIL %s\n' "$name"
+ FAIL=$((FAIL + 1))
+ fi
+}
+
+test_resolves_root_when_sourced_elsewhere() (
+ [ -r "$SCRIPT" ] || return 1
+ cd /tmp || return 1
+ source "$SCRIPT"
+ [ "$ROOT_DIR" = "$PROJECT_ROOT" ]
+ [ -z "$BACKEND_PID" ]
+ [ -z "$NGROK_PID" ]
+)
+
+test_reuses_healthy_backend() (
+ source "$SCRIPT"
+ declare -F ensure_backend >/dev/null || return 1
+ backend_is_healthy() { return 0; }
+ port_listener_pid() { printf '4242\n'; }
+ start_backend() { return 77; }
+ ensure_backend
+ [ -z "$BACKEND_PID" ]
+)
+
+test_rejects_unhealthy_port_without_killing_owner() (
+ source "$SCRIPT"
+ declare -F ensure_backend >/dev/null || return 1
+ sleep 30 &
+ local external_pid=$!
+ trap "kill '$external_pid' 2>/dev/null || true" EXIT
+ backend_is_healthy() { return 1; }
+ port_listener_pid() { printf '%s\n' "$external_pid"; }
+ if ensure_backend; then return 1; fi
+ kill -0 "$external_pid" 2>/dev/null || return 1
+ kill "$external_pid" 2>/dev/null || true
+ wait "$external_pid" 2>/dev/null || true
+ trap - EXIT
+)
+
+test_cleanup_stops_owned_pid_only() (
+ source "$SCRIPT"
+ declare -F cleanup >/dev/null || return 1
+ local ready="${TMPDIR:-/tmp}/run-command-owned-ready-$$"
+ rm -f "$ready"
+ python3 -c 'import signal,sys,time; signal.signal(signal.SIGTERM, lambda *_: sys.exit(0)); open(sys.argv[1], "w").close(); time.sleep(30)' "$ready" &
+ BACKEND_PID=$!
+ local attempt=0
+ while [ ! -e "$ready" ] && [ "$attempt" -lt 50 ]; do
+ sleep 0.02
+ attempt=$((attempt + 1))
+ done
+ [ -e "$ready" ] || return 1
+ sleep 30 &
+ local external_pid=$!
+ cleanup
+ if kill -0 "$BACKEND_PID" 2>/dev/null; then return 1; fi
+ kill -0 "$external_pid" 2>/dev/null || return 1
+ kill "$external_pid" 2>/dev/null || true
+ wait "$external_pid" 2>/dev/null || true
+ rm -f "$ready"
+)
+
+test_reuses_healthy_ngrok() (
+ source "$SCRIPT"
+ declare -F ensure_ngrok >/dev/null || return 1
+ ngrok_is_healthy() { return 0; }
+ port_listener_pid() { printf '5252\n'; }
+ start_ngrok() { return 78; }
+ ensure_ngrok
+ [ -z "$NGROK_PID" ]
+)
+
+test_no_open_mode_skips_browser() (
+ source "$SCRIPT"
+ declare -F maybe_open_browser >/dev/null || return 1
+ local marker="${TMPDIR:-/tmp}/run-command-open-$$"
+ rm -f "$marker"
+ open() { : > "$marker"; }
+ ERP_RUN_NO_OPEN=1
+ maybe_open_browser
+ [ ! -e "$marker" ]
+)
+
+test_main_runs_steps_in_order() (
+ source "$SCRIPT"
+ local events="${TMPDIR:-/tmp}/run-command-events-$$"
+ : > "$events"
+ preflight() { printf 'preflight\n' >> "$events"; }
+ ensure_backend() { printf 'backend\n' >> "$events"; }
+ ensure_ngrok() { printf 'ngrok\n' >> "$events"; }
+ wait_for_public() { printf 'public\n' >> "$events"; }
+ maybe_open_browser() { printf 'open\n' >> "$events"; }
+ monitor_services() { printf 'monitor\n' >> "$events"; }
+ main
+ [ "$(tr '\n' ' ' < "$events")" = 'preflight backend ngrok public open monitor ' ]
+)
+
+run_test 'resolves project root when sourced elsewhere' test_resolves_root_when_sourced_elsewhere
+run_test 'reuses a healthy backend' test_reuses_healthy_backend
+run_test 'rejects a foreign 8091 listener without killing it' test_rejects_unhealthy_port_without_killing_owner
+run_test 'cleanup stops owned PID only' test_cleanup_stops_owned_pid_only
+run_test 'reuses a healthy ngrok tunnel' test_reuses_healthy_ngrok
+run_test 'no-open mode skips browser launch' test_no_open_mode_skips_browser
+run_test 'main orchestrates steps in order' test_main_runs_steps_in_order
+printf 'RESULT pass=%s fail=%s\n' "$PASS" "$FAIL"
+[ "$FAIL" -eq 0 ]