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

517 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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" == *'<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"
}
```
- [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" == *'<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
}
```
- [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 '<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.
- [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"
```