407 lines
11 KiB
Python
Raw 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.

#!/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())