Files
ERP/docs/superpowers/plans/2026-07-15-run-command.md
Qiufeng 2524a37a07 Make local ERP previews one double-click away
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
2026-07-15 16:29:05 +08:00

16 KiB
Raw Permalink Blame History

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

  • 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:

#!/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 ]
  • Step 2: Run the test and verify RED

Run:

bash tests/run-command.test.sh

Expected: exit non-zero with FAIL resolves project root when sourced elsewhere because run.command does not exist.

  • Step 3: Add the minimal source-safe launcher skeleton

Create run.command with project-relative constants, empty ownership state, and a direct-execution guard:

#!/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
  • Step 4: Run syntax and source tests and verify GREEN

Run:

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

  • Step 1: Add failing backend and cleanup tests

Insert these tests before the runner calls, then add their run_test calls:

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
  • 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.

  • Step 3: Implement minimal backend and ownership functions

Add these functions above main in run.command:

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" == *'<title>凯迪协同办公平台</title>'* ]]
}

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"
}
  • 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

  • Step 1: Add failing orchestration tests

Add these tests and runner calls:

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
  • 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.

  • Step 3: Implement preflight, ngrok, public verification, and main

Add the following functions above main, then replace the empty main:

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" == *'<title>凯迪协同办公平台</title>'* ]]
}

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
}
  • Step 4: Run the complete unit suite and verify GREEN

Run:

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

  • Step 1: Mark both scripts executable

Run:

chmod +x run.command tests/run-command.test.sh
  • 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:

ERP_RUN_NO_OPEN=1 ./run.command

Expected: output says both services are reused; after Ctrl+C, ports 8091 and 4040 remain listening.

  • 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:

curl -fsS http://127.0.0.1:8091/ | grep -F '<title>凯迪协同办公平台</title>'
curl -fsS -H 'ngrok-skip-browser-warning: true' https://resonant-elated-launder.ngrok-free.dev/ | grep -F '<title>凯迪协同办公平台</title>'

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.

  • 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.

  • Step 5: Commit the implementation with Lore trailers

Stage only the two implementation files and this plan:

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"