chore: 新增本地开发环境启停脚本
This commit is contained in:
parent
41a42bca25
commit
54abe383c0
406
start.py
Normal file
406
start.py
Normal file
@ -0,0 +1,406 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
STATE_DIR = ROOT / ".tmp" / "dev-manager"
|
||||||
|
STATE_FILE = STATE_DIR / "state.json"
|
||||||
|
LOG_FILE = STATE_DIR / "dev.log"
|
||||||
|
ERR_FILE = STATE_DIR / "dev.err.log"
|
||||||
|
SUPERVISOR_MODE = "__serve"
|
||||||
|
API_URL = "http://localhost:3001"
|
||||||
|
WEB_URL = "http://localhost:5173"
|
||||||
|
NPM_EXECUTABLE = "npm.cmd" if os.name == "nt" else "npm"
|
||||||
|
|
||||||
|
|
||||||
|
def now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def print_line(message: str) -> None:
|
||||||
|
print(message, flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_state_dir() -> None:
|
||||||
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def append_log_header(path: Path, title: str) -> None:
|
||||||
|
timestamp = now_iso()
|
||||||
|
with path.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(f"\n[{timestamp}] {title}\n")
|
||||||
|
|
||||||
|
|
||||||
|
def read_state() -> dict[str, Any] | None:
|
||||||
|
if not STATE_FILE.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def write_state(state: dict[str, Any]) -> None:
|
||||||
|
ensure_state_dir()
|
||||||
|
STATE_FILE.write_text(
|
||||||
|
json.dumps(state, ensure_ascii=True, indent=2),
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_state(expected_token: str | None = None) -> None:
|
||||||
|
if not STATE_FILE.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
if expected_token is not None:
|
||||||
|
current = read_state()
|
||||||
|
if not current or current.get("token") != expected_token:
|
||||||
|
return
|
||||||
|
|
||||||
|
STATE_FILE.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def process_exists(pid: int) -> bool:
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return False
|
||||||
|
except PermissionError:
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def get_process_commandline(pid: int) -> str | None:
|
||||||
|
if os.name == "nt":
|
||||||
|
command = [
|
||||||
|
"powershell",
|
||||||
|
"-NoProfile",
|
||||||
|
"-Command",
|
||||||
|
(
|
||||||
|
f'$process = Get-CimInstance Win32_Process -Filter "ProcessId = {pid}"; '
|
||||||
|
'if ($process) { [Console]::Out.Write($process.CommandLine) }'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
command = ["ps", "-o", "command=", "-p", str(pid)]
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
command,
|
||||||
|
cwd=str(ROOT),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="ignore",
|
||||||
|
check=False
|
||||||
|
)
|
||||||
|
|
||||||
|
commandline = result.stdout.strip()
|
||||||
|
return commandline or None
|
||||||
|
|
||||||
|
|
||||||
|
def is_expected_supervisor(state: dict[str, Any]) -> bool:
|
||||||
|
pid = int(state.get("pid", 0))
|
||||||
|
token = str(state.get("token", "")).strip()
|
||||||
|
if pid <= 0 or not token or not process_exists(pid):
|
||||||
|
return False
|
||||||
|
|
||||||
|
commandline = get_process_commandline(pid)
|
||||||
|
if not commandline:
|
||||||
|
return True
|
||||||
|
|
||||||
|
expected_fragments = [Path(__file__).name, SUPERVISOR_MODE, token]
|
||||||
|
return all(fragment in commandline for fragment in expected_fragments)
|
||||||
|
|
||||||
|
|
||||||
|
def get_running_state() -> dict[str, Any] | None:
|
||||||
|
state = read_state()
|
||||||
|
if not state:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not is_expected_supervisor(state):
|
||||||
|
remove_state()
|
||||||
|
return None
|
||||||
|
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def tail_text(path: Path, line_count: int = 20) -> str:
|
||||||
|
if not path.exists():
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = path.read_text(encoding="utf-8", errors="ignore")
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
lines = content.splitlines()
|
||||||
|
if not lines:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return "\n".join(lines[-line_count:])
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_npm_available() -> bool:
|
||||||
|
return shutil.which(NPM_EXECUTABLE) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_process_exit(pid: int, attempts: int = 20, interval: float = 0.25) -> bool:
|
||||||
|
for _ in range(attempts):
|
||||||
|
if not process_exists(pid):
|
||||||
|
return True
|
||||||
|
time.sleep(interval)
|
||||||
|
return not process_exists(pid)
|
||||||
|
|
||||||
|
|
||||||
|
def stop_process_tree(pid: int) -> bool:
|
||||||
|
if os.name == "nt":
|
||||||
|
graceful = subprocess.run(
|
||||||
|
["taskkill", "/PID", str(pid), "/T"],
|
||||||
|
cwd=str(ROOT),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False
|
||||||
|
)
|
||||||
|
if graceful.returncode == 0:
|
||||||
|
if wait_for_process_exit(pid):
|
||||||
|
return True
|
||||||
|
|
||||||
|
forced = subprocess.run(
|
||||||
|
["taskkill", "/PID", str(pid), "/T", "/F"],
|
||||||
|
cwd=str(ROOT),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False
|
||||||
|
)
|
||||||
|
if forced.returncode == 0:
|
||||||
|
wait_for_process_exit(pid)
|
||||||
|
return True
|
||||||
|
return not process_exists(pid)
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
||||||
|
except OSError:
|
||||||
|
return not process_exists(pid)
|
||||||
|
|
||||||
|
if wait_for_process_exit(pid):
|
||||||
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.killpg(os.getpgid(pid), signal.SIGKILL)
|
||||||
|
except OSError:
|
||||||
|
return not process_exists(pid)
|
||||||
|
|
||||||
|
if wait_for_process_exit(pid):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return not process_exists(pid)
|
||||||
|
|
||||||
|
|
||||||
|
def start_supervisor() -> int:
|
||||||
|
ensure_state_dir()
|
||||||
|
append_log_header(LOG_FILE, "starting root dev command")
|
||||||
|
append_log_header(ERR_FILE, "starting root dev command")
|
||||||
|
|
||||||
|
token = uuid.uuid4().hex
|
||||||
|
creation_flags = 0
|
||||||
|
if os.name == "nt":
|
||||||
|
creation_flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||||
|
creation_flags |= getattr(subprocess, "DETACHED_PROCESS", 0)
|
||||||
|
|
||||||
|
process = subprocess.Popen(
|
||||||
|
[sys.executable, str(Path(__file__).resolve()), SUPERVISOR_MODE, token],
|
||||||
|
cwd=str(ROOT),
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
creationflags=creation_flags,
|
||||||
|
close_fds=True
|
||||||
|
)
|
||||||
|
|
||||||
|
deadline = time.monotonic() + 10
|
||||||
|
state: dict[str, Any] | None = None
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
current = read_state()
|
||||||
|
if current and current.get("token") == token and is_expected_supervisor(current):
|
||||||
|
state = current
|
||||||
|
break
|
||||||
|
if process.poll() is not None:
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
if not state:
|
||||||
|
print_line("启动失败:后台 supervisor 没有成功创建。")
|
||||||
|
error_tail = tail_text(ERR_FILE)
|
||||||
|
if error_tail:
|
||||||
|
print_line(error_tail)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
time.sleep(3)
|
||||||
|
state = get_running_state()
|
||||||
|
if not state:
|
||||||
|
print_line("启动失败:`npm run dev` 很快退出了。")
|
||||||
|
error_tail = tail_text(ERR_FILE)
|
||||||
|
if error_tail:
|
||||||
|
print_line(error_tail)
|
||||||
|
else:
|
||||||
|
log_tail = tail_text(LOG_FILE)
|
||||||
|
if log_tail:
|
||||||
|
print_line(log_tail)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print_line("已启动前后端开发环境。")
|
||||||
|
print_line(f"Web: {WEB_URL}")
|
||||||
|
print_line(f"API: {API_URL}")
|
||||||
|
print_line(f"状态文件: {STATE_FILE}")
|
||||||
|
print_line(f"日志: {LOG_FILE}")
|
||||||
|
print_line("关闭命令: python start.py stop")
|
||||||
|
print_line("无参数执行时会自动在启动和关闭之间切换。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def stop_supervisor() -> int:
|
||||||
|
state = get_running_state()
|
||||||
|
if not state:
|
||||||
|
remove_state()
|
||||||
|
print_line("当前没有运行中的开发环境。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
pid = int(state["pid"])
|
||||||
|
token = str(state["token"])
|
||||||
|
stopped = stop_process_tree(pid)
|
||||||
|
remove_state(expected_token=token)
|
||||||
|
|
||||||
|
if stopped:
|
||||||
|
print_line("已关闭前后端开发环境。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print_line(f"关闭失败:无法结束 supervisor 进程 {pid}。")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def show_status() -> int:
|
||||||
|
state = get_running_state()
|
||||||
|
if not state:
|
||||||
|
print_line("状态: 未运行")
|
||||||
|
print_line("启动命令: python start.py start")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
print_line("状态: 运行中")
|
||||||
|
print_line(f"Supervisor PID: {state['pid']}")
|
||||||
|
child_pid = state.get("childPid")
|
||||||
|
if child_pid:
|
||||||
|
print_line(f"Root dev PID: {child_pid}")
|
||||||
|
print_line(f"启动时间: {state['startedAt']}")
|
||||||
|
print_line(f"Web: {state['webUrl']}")
|
||||||
|
print_line(f"API: {state['apiUrl']}")
|
||||||
|
print_line(f"日志: {state['logFile']}")
|
||||||
|
print_line(f"错误日志: {state['errorLogFile']}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def run_supervisor(token: str) -> int:
|
||||||
|
ensure_state_dir()
|
||||||
|
state = {
|
||||||
|
"pid": os.getpid(),
|
||||||
|
"token": token,
|
||||||
|
"startedAt": now_iso(),
|
||||||
|
"root": str(ROOT),
|
||||||
|
"logFile": str(LOG_FILE),
|
||||||
|
"errorLogFile": str(ERR_FILE),
|
||||||
|
"webUrl": WEB_URL,
|
||||||
|
"apiUrl": API_URL,
|
||||||
|
"command": [NPM_EXECUTABLE, "run", "dev"]
|
||||||
|
}
|
||||||
|
write_state(state)
|
||||||
|
|
||||||
|
with LOG_FILE.open("a", encoding="utf-8") as stdout_handle, ERR_FILE.open(
|
||||||
|
"a",
|
||||||
|
encoding="utf-8"
|
||||||
|
) as stderr_handle:
|
||||||
|
stdout_handle.write(f"[{now_iso()}] supervisor pid={os.getpid()}\n")
|
||||||
|
stdout_handle.flush()
|
||||||
|
|
||||||
|
child = subprocess.Popen(
|
||||||
|
[NPM_EXECUTABLE, "run", "dev"],
|
||||||
|
cwd=str(ROOT),
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=stdout_handle,
|
||||||
|
stderr=stderr_handle
|
||||||
|
)
|
||||||
|
|
||||||
|
state["childPid"] = child.pid
|
||||||
|
write_state(state)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return child.wait()
|
||||||
|
finally:
|
||||||
|
append_log_header(LOG_FILE, "root dev command stopped")
|
||||||
|
remove_state(expected_token=token)
|
||||||
|
|
||||||
|
|
||||||
|
def show_usage() -> int:
|
||||||
|
print_line("用法: python start.py [start|stop|restart|status|toggle]")
|
||||||
|
print_line("无参数默认执行 toggle。")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
action = sys.argv[1].strip().lower() if len(sys.argv) > 1 else "toggle"
|
||||||
|
|
||||||
|
if action == SUPERVISOR_MODE:
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
return 1
|
||||||
|
return run_supervisor(sys.argv[2])
|
||||||
|
|
||||||
|
if action == "start":
|
||||||
|
running = get_running_state()
|
||||||
|
if running:
|
||||||
|
print_line("开发环境已在运行。")
|
||||||
|
return show_status()
|
||||||
|
if not ensure_npm_available():
|
||||||
|
print_line("未找到 npm,请先安装 Node.js 并确认 npm 已加入 PATH。")
|
||||||
|
return 1
|
||||||
|
return start_supervisor()
|
||||||
|
|
||||||
|
if action == "stop":
|
||||||
|
return stop_supervisor()
|
||||||
|
|
||||||
|
if action == "restart":
|
||||||
|
stop_code = stop_supervisor()
|
||||||
|
if stop_code != 0:
|
||||||
|
return stop_code
|
||||||
|
if not ensure_npm_available():
|
||||||
|
print_line("未找到 npm,请先安装 Node.js 并确认 npm 已加入 PATH。")
|
||||||
|
return 1
|
||||||
|
return start_supervisor()
|
||||||
|
|
||||||
|
if action == "status":
|
||||||
|
return show_status()
|
||||||
|
|
||||||
|
if action == "toggle":
|
||||||
|
if get_running_state():
|
||||||
|
return stop_supervisor()
|
||||||
|
if not ensure_npm_available():
|
||||||
|
print_line("未找到 npm,请先安装 Node.js 并确认 npm 已加入 PATH。")
|
||||||
|
return 1
|
||||||
|
return start_supervisor()
|
||||||
|
|
||||||
|
return show_usage()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Loading…
x
Reference in New Issue
Block a user