init
This commit is contained in:
@@ -0,0 +1,593 @@
|
||||
#!/usr/bin/env python3
|
||||
"""宿舍报修系统 —— 统一项目管理 CLI
|
||||
|
||||
用法:
|
||||
./cli.py setup 安装全部依赖
|
||||
./cli.py start [backend|frontend|all] 启动服务
|
||||
./cli.py stop [backend|frontend|all] 停止服务
|
||||
./cli.py restart [backend|frontend|all] 重启服务
|
||||
./cli.py status 查看服务状态
|
||||
./cli.py logs [backend|frontend] [-f] 查看日志
|
||||
./cli.py seed [--force] [--db PATH] 写入演示数据
|
||||
./cli.py lint 运行代码检查
|
||||
./cli.py typecheck TypeScript 类型检查
|
||||
./cli.py build 生产构建(前端)
|
||||
./cli.py clean [--runtime|--db|--deps|--all] 清理临时文件
|
||||
./cli.py env 创建 .env 配置
|
||||
./cli.py docker [up|down|build] Docker 快捷操作
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
IS_WINDOWS = sys.platform == "win32"
|
||||
|
||||
# Auto-activate virtualenv if present
|
||||
_VENV = Path(__file__).resolve().parent / ".venv"
|
||||
_VENV_PYTHON = _VENV / ("Scripts" if IS_WINDOWS else "bin") / ("python.exe" if IS_WINDOWS else "python")
|
||||
if _VENV_PYTHON.is_file() and Path(sys.executable).resolve() != _VENV_PYTHON.resolve():
|
||||
os.execv(str(_VENV_PYTHON), [str(_VENV_PYTHON), *sys.argv])
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||
BACKEND_DIR = PROJECT_ROOT / "backend"
|
||||
FRONTEND_DIR = PROJECT_ROOT / "frontend"
|
||||
RUNTIME_DIR = PROJECT_ROOT / ".runtime" / "dev-services"
|
||||
STATE_PATH = RUNTIME_DIR / "state.json"
|
||||
DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
# ── Color helpers ──────────────────────────────────────────────
|
||||
_BOLD = "\033[1m"
|
||||
_GREEN = "\033[32m"
|
||||
_YELLOW = "\033[33m"
|
||||
_RED = "\033[31m"
|
||||
_CYAN = "\033[36m"
|
||||
_RESET = "\033[0m"
|
||||
|
||||
|
||||
def _green(s: str) -> str:
|
||||
return f"{_GREEN}{s}{_RESET}"
|
||||
|
||||
|
||||
def _yellow(s: str) -> str:
|
||||
return f"{_YELLOW}{s}{_RESET}"
|
||||
|
||||
|
||||
def _red(s: str) -> str:
|
||||
return f"{_RED}{s}{_RESET}"
|
||||
|
||||
|
||||
def _cyan(s: str) -> str:
|
||||
return f"{_CYAN}{s}{_RESET}"
|
||||
|
||||
|
||||
def _bold(s: str) -> str:
|
||||
return f"{_BOLD}{s}{_RESET}"
|
||||
|
||||
|
||||
# ── Configuration ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
backend_host: str = "127.0.0.1"
|
||||
backend_port: int = 8000
|
||||
frontend_host: str = "127.0.0.1"
|
||||
frontend_port: int = 5173
|
||||
default_db: Path = BACKEND_DIR / "data" / "dorm_repair.sqlite3"
|
||||
|
||||
@property
|
||||
def backend_url(self) -> str:
|
||||
return f"http://{self.backend_host}:{self.backend_port}"
|
||||
|
||||
@property
|
||||
def frontend_url(self) -> str:
|
||||
return f"http://{self.frontend_host}:{self.frontend_port}"
|
||||
|
||||
@property
|
||||
def backend_health_url(self) -> str:
|
||||
return f"{self.backend_url}/api/health"
|
||||
|
||||
|
||||
CONFIG = Config()
|
||||
|
||||
|
||||
# ── Service definitions ────────────────────────────────────────
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Service:
|
||||
name: str
|
||||
command: list[str]
|
||||
cwd: Path
|
||||
url: str
|
||||
host: str
|
||||
port: int
|
||||
env: dict[str, str] | None = None
|
||||
|
||||
@property
|
||||
def log_path(self) -> Path:
|
||||
return RUNTIME_DIR / f"{self.name}.log"
|
||||
|
||||
|
||||
SERVICES = {
|
||||
"backend": Service(
|
||||
name="backend",
|
||||
command=[
|
||||
"uv",
|
||||
"run",
|
||||
"uvicorn",
|
||||
"app.main:app",
|
||||
"--reload",
|
||||
"--host",
|
||||
CONFIG.backend_host,
|
||||
"--port",
|
||||
str(CONFIG.backend_port),
|
||||
],
|
||||
cwd=BACKEND_DIR,
|
||||
url=CONFIG.backend_health_url,
|
||||
host=CONFIG.backend_host,
|
||||
port=CONFIG.backend_port,
|
||||
env={"UV_CACHE_DIR": str(RUNTIME_DIR / "uv-cache")},
|
||||
),
|
||||
"frontend": Service(
|
||||
name="frontend",
|
||||
command=[
|
||||
"pnpm",
|
||||
"dev",
|
||||
"--host",
|
||||
CONFIG.frontend_host,
|
||||
"--port",
|
||||
str(CONFIG.frontend_port),
|
||||
"--strictPort",
|
||||
],
|
||||
cwd=FRONTEND_DIR,
|
||||
url=CONFIG.frontend_url + "/",
|
||||
host=CONFIG.frontend_host,
|
||||
port=CONFIG.frontend_port,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ── State management ───────────────────────────────────────────
|
||||
|
||||
|
||||
def _load_state() -> dict[str, Any]:
|
||||
if not STATE_PATH.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(STATE_PATH.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_state(state: dict[str, Any]) -> None:
|
||||
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
|
||||
STATE_PATH.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
# ── Process helpers ────────────────────────────────────────────
|
||||
|
||||
|
||||
def _pid_running(pid: int) -> bool:
|
||||
if IS_WINDOWS:
|
||||
result = subprocess.run(
|
||||
["tasklist", "/FI", f"PID eq {pid}", "/NH"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return str(pid) in result.stdout
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def _service_pid(state: dict[str, Any], svc: Service) -> int | None:
|
||||
raw = state.get(svc.name, {}).get("pid")
|
||||
if not isinstance(raw, int):
|
||||
return None
|
||||
return raw if _pid_running(raw) else None
|
||||
|
||||
|
||||
def _port_open(host: str, port: int) -> bool:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(0.2)
|
||||
return sock.connect_ex((host, port)) == 0
|
||||
|
||||
|
||||
# ── Service lifecycle ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _start_service(svc: Service, state: dict[str, Any]) -> None:
|
||||
pid = _service_pid(state, svc)
|
||||
if pid is not None:
|
||||
print(f" {svc.name}: 已在运行 pid={pid}")
|
||||
return
|
||||
|
||||
if _port_open(svc.host, svc.port):
|
||||
print(f" {_yellow(svc.name)}: 端口 {svc.host}:{svc.port} 已被占用")
|
||||
return
|
||||
|
||||
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log_file = svc.log_path.open("ab")
|
||||
popen_kwargs: dict[str, Any] = {
|
||||
"cwd": svc.cwd,
|
||||
"env": {**os.environ, **(svc.env or {})},
|
||||
"stdin": subprocess.DEVNULL,
|
||||
"stdout": log_file,
|
||||
"stderr": subprocess.STDOUT,
|
||||
}
|
||||
if IS_WINDOWS and hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
|
||||
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
else:
|
||||
popen_kwargs["start_new_session"] = True
|
||||
proc = subprocess.Popen(svc.command, **popen_kwargs)
|
||||
state[svc.name] = {
|
||||
"pid": proc.pid,
|
||||
"command": svc.command,
|
||||
"cwd": str(svc.cwd),
|
||||
"log": str(svc.log_path),
|
||||
"url": svc.url,
|
||||
"started_at": time.strftime(DATETIME_FORMAT),
|
||||
}
|
||||
print(f" {_green(svc.name)}: 已启动 pid={proc.pid}")
|
||||
|
||||
|
||||
def _stop_service(svc: Service, state: dict[str, Any]) -> None:
|
||||
pid = _service_pid(state, svc)
|
||||
if pid is None:
|
||||
print(f" {svc.name}: 未运行")
|
||||
state.pop(svc.name, None)
|
||||
return
|
||||
|
||||
print(f" {svc.name}: 正在停止 pid={pid}")
|
||||
|
||||
if IS_WINDOWS:
|
||||
subprocess.run(["taskkill", "/PID", str(pid), "/T"], capture_output=True)
|
||||
else:
|
||||
try:
|
||||
os.killpg(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
state.pop(svc.name, None)
|
||||
return
|
||||
|
||||
deadline = time.time() + 8
|
||||
while time.time() < deadline:
|
||||
if not _pid_running(pid):
|
||||
state.pop(svc.name, None)
|
||||
print(f" {svc.name}: 已停止")
|
||||
return
|
||||
time.sleep(0.2)
|
||||
|
||||
print(f" {_yellow(svc.name)}: 未响应,强制终止")
|
||||
if IS_WINDOWS:
|
||||
subprocess.run(["taskkill", "/F", "/PID", str(pid), "/T"], capture_output=True)
|
||||
else:
|
||||
try:
|
||||
os.killpg(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
state.pop(svc.name, None)
|
||||
|
||||
|
||||
def _status_service(svc: Service, state: dict[str, Any]) -> None:
|
||||
pid = _service_pid(state, svc)
|
||||
if pid is None:
|
||||
suffix = " (端口被占用)" if _port_open(svc.host, svc.port) else ""
|
||||
print(f" {svc.name}: 未运行{suffix}")
|
||||
return
|
||||
print(f" {_green(svc.name)}: 运行中 pid={pid} url={svc.url}")
|
||||
|
||||
|
||||
def _pick_services(names: list[str]) -> list[Service]:
|
||||
if not names or "all" in names:
|
||||
return list(SERVICES.values())
|
||||
selected: list[Service] = []
|
||||
for name in names:
|
||||
svc = SERVICES.get(name)
|
||||
if svc is None:
|
||||
choices = ", ".join(["all", *SERVICES])
|
||||
raise SystemExit(f"未知服务 '{name}',可选: {choices}")
|
||||
selected.append(svc)
|
||||
return selected
|
||||
|
||||
|
||||
# ── Commands ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def cmd_setup() -> int:
|
||||
"""安装后端和前端依赖。"""
|
||||
print(_bold("安装后端依赖 (uv sync) ..."))
|
||||
subprocess.run(["uv", "sync"], cwd=BACKEND_DIR, check=True)
|
||||
print(_bold("安装前端依赖 (pnpm install) ..."))
|
||||
subprocess.run(["pnpm", "install"], cwd=FRONTEND_DIR, check=True)
|
||||
print(_green("安装完成。"))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_start(services: list[str]) -> int:
|
||||
state = _load_state()
|
||||
svcs = _pick_services(services)
|
||||
for svc in svcs:
|
||||
_start_service(svc, state)
|
||||
_save_state(state)
|
||||
if svcs:
|
||||
_print_urls(svcs)
|
||||
return 0
|
||||
|
||||
|
||||
def _print_urls(svcs: list[Service]) -> None:
|
||||
print()
|
||||
for svc in svcs:
|
||||
label = "后端" if svc.name == "backend" else "前端"
|
||||
print(f" {label}: {_cyan(svc.url)}")
|
||||
print()
|
||||
|
||||
|
||||
def cmd_stop(services: list[str]) -> int:
|
||||
state = _load_state()
|
||||
for svc in reversed(_pick_services(services)):
|
||||
_stop_service(svc, state)
|
||||
_save_state(state)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_restart(services: list[str]) -> int:
|
||||
state = _load_state()
|
||||
svcs = _pick_services(services)
|
||||
for svc in reversed(svcs):
|
||||
_stop_service(svc, state)
|
||||
for svc in svcs:
|
||||
_start_service(svc, state)
|
||||
_save_state(state)
|
||||
if svcs:
|
||||
_print_urls(svcs)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_status() -> int:
|
||||
state = _load_state()
|
||||
for svc in SERVICES.values():
|
||||
_status_service(svc, state)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_logs(service: str, follow: bool) -> int:
|
||||
svc = SERVICES.get(service)
|
||||
if svc is None:
|
||||
raise SystemExit(f"未知服务 '{service}',可选: backend, frontend")
|
||||
log_path = svc.log_path
|
||||
if not log_path.exists():
|
||||
print(f"日志文件不存在: {log_path}")
|
||||
return 0
|
||||
|
||||
if follow:
|
||||
_follow_log(log_path)
|
||||
else:
|
||||
content = log_path.read_text(encoding="utf-8", errors="replace")
|
||||
for line in content.splitlines()[-50:]:
|
||||
print(line)
|
||||
return 0
|
||||
|
||||
|
||||
def _follow_log(path: Path) -> None:
|
||||
if IS_WINDOWS:
|
||||
with path.open("r", encoding="utf-8", errors="replace") as f:
|
||||
f.seek(0, 2)
|
||||
while True:
|
||||
line = f.readline()
|
||||
if line:
|
||||
print(line, end="")
|
||||
else:
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
subprocess.run(["tail", "-n", "50", "-f", str(path)])
|
||||
|
||||
|
||||
def cmd_seed(force: bool, db_path: str | None) -> int:
|
||||
"""Seed demo data via standalone seed script."""
|
||||
db = db_path or str(CONFIG.default_db)
|
||||
seed_script = PROJECT_ROOT / "scripts" / "_seed.py"
|
||||
print(_bold("Seeding demo data ..."))
|
||||
subprocess.run(
|
||||
["uv", "run", "python", str(seed_script), "--db", db] + (["--force"] if force else []),
|
||||
cwd=BACKEND_DIR,
|
||||
check=True,
|
||||
)
|
||||
print(f" student01 / Student123 {_cyan('(student)')}")
|
||||
print(f" admin01 / Admin123 {_cyan('(admin)')}")
|
||||
print(" 8 sample repair orders covering all statuses")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_lint() -> int:
|
||||
print(_bold("Backend lint (ruff) ..."))
|
||||
subprocess.run(["uv", "run", "ruff", "check", "backend"], check=False)
|
||||
subprocess.run(["uv", "run", "ruff", "format", "--check", "backend"], check=False)
|
||||
print(_bold("Frontend lint (eslint) ..."))
|
||||
subprocess.run(["pnpm", "lint"], cwd=FRONTEND_DIR, check=False)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_typecheck() -> int:
|
||||
print(_bold("TypeScript type check ..."))
|
||||
subprocess.run(["pnpm", "typecheck"], cwd=FRONTEND_DIR, check=False)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_build() -> int:
|
||||
print(_bold("Building frontend ..."))
|
||||
subprocess.run(["pnpm", "build"], cwd=FRONTEND_DIR, check=True)
|
||||
print(_green(f"Output: {FRONTEND_DIR / 'dist'}/"))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_clean(runtime: bool, db: bool, deps: bool, all_: bool) -> int:
|
||||
if all_:
|
||||
runtime = db = deps = True
|
||||
|
||||
if runtime:
|
||||
if RUNTIME_DIR.exists():
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(RUNTIME_DIR)
|
||||
print(f"Removed {RUNTIME_DIR}")
|
||||
else:
|
||||
print("No runtime dir to clean.")
|
||||
|
||||
if db:
|
||||
db_path = CONFIG.default_db
|
||||
if db_path.exists():
|
||||
db_path.unlink()
|
||||
print(f"Removed {db_path}")
|
||||
else:
|
||||
print("No database to clean.")
|
||||
|
||||
if deps:
|
||||
for d in [BACKEND_DIR / ".venv", FRONTEND_DIR / "node_modules"]:
|
||||
if d.exists():
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(d)
|
||||
print(f"Removed {d}")
|
||||
|
||||
print(_green("Clean complete."))
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_env() -> int:
|
||||
for template, target in [
|
||||
(BACKEND_DIR / ".env.example", BACKEND_DIR / ".env"),
|
||||
(FRONTEND_DIR / ".env.example", FRONTEND_DIR / ".env"),
|
||||
]:
|
||||
if not template.exists():
|
||||
print(f"Template not found: {template}")
|
||||
continue
|
||||
if target.exists():
|
||||
print(f"{_yellow('Skipped')} {target} (already exists)")
|
||||
continue
|
||||
target.write_text(template.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
print(f"Created {target} from {template.name}")
|
||||
print(_green("Environment files ready. Edit them if needed:"))
|
||||
print(f" {BACKEND_DIR / '.env'}")
|
||||
print(f" {FRONTEND_DIR / '.env'}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_docker(action: str) -> int:
|
||||
compose_file = PROJECT_ROOT / "docker-compose.yml"
|
||||
if not compose_file.exists():
|
||||
raise SystemExit("docker-compose.yml not found.")
|
||||
if action == "up":
|
||||
subprocess.run(["docker", "compose", "up", "-d"], check=True)
|
||||
elif action == "down":
|
||||
subprocess.run(["docker", "compose", "down"], check=True)
|
||||
elif action == "build":
|
||||
subprocess.run(["docker", "compose", "build"], check=True)
|
||||
else:
|
||||
raise SystemExit(f"Unknown docker action: {action}")
|
||||
return 0
|
||||
|
||||
|
||||
# ── Argument parsing ───────────────────────────────────────────
|
||||
|
||||
|
||||
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="宿舍报修系统 —— 项目管理 CLI",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
sub.add_parser("setup", help="安装全部依赖(uv sync + pnpm install)")
|
||||
|
||||
for cmd_name in ("start", "stop", "restart"):
|
||||
label = {"start": "启动", "stop": "停止", "restart": "重启"}[cmd_name]
|
||||
p = sub.add_parser(cmd_name, help=f"{label}服务")
|
||||
p.add_argument(
|
||||
"services",
|
||||
nargs="*",
|
||||
default=["all"],
|
||||
help="目标服务:all, backend, frontend(默认: all)",
|
||||
)
|
||||
|
||||
sub.add_parser("status", help="查看服务状态")
|
||||
|
||||
log_p = sub.add_parser("logs", help="查看服务日志")
|
||||
log_p.add_argument("service", choices=["backend", "frontend"], help="服务名")
|
||||
log_p.add_argument("-f", "--follow", action="store_true", help="实时跟踪日志")
|
||||
|
||||
seed_p = sub.add_parser("seed", help="写入演示数据")
|
||||
seed_p.add_argument("--force", action="store_true", help="覆盖已有数据")
|
||||
seed_p.add_argument("--db", type=str, default=None, help="SQLite 数据库路径")
|
||||
|
||||
sub.add_parser("lint", help="运行代码检查(ruff + eslint)")
|
||||
sub.add_parser("typecheck", help="TypeScript 类型检查(vue-tsc)")
|
||||
sub.add_parser("build", help="生产构建(前端)")
|
||||
|
||||
clean_p = sub.add_parser("clean", help="清理临时文件")
|
||||
clean_p.add_argument("--runtime", action="store_true", help="删除 .runtime/")
|
||||
clean_p.add_argument("--db", action="store_true", help="删除 SQLite 数据库")
|
||||
clean_p.add_argument("--deps", action="store_true", help="删除 .venv 和 node_modules")
|
||||
clean_p.add_argument("--all", action="store_true", help="清理全部")
|
||||
|
||||
sub.add_parser("env", help="从 .env.example 创建 .env 配置文件")
|
||||
|
||||
docker_p = sub.add_parser("docker", help="Docker 快捷操作")
|
||||
docker_p.add_argument("action", choices=["up", "down", "build"], help="Docker 操作")
|
||||
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
# ── Main ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parse_args(argv)
|
||||
cmd = args.command
|
||||
|
||||
if cmd == "setup":
|
||||
return cmd_setup()
|
||||
elif cmd == "start":
|
||||
return cmd_start(args.services)
|
||||
elif cmd == "stop":
|
||||
return cmd_stop(args.services)
|
||||
elif cmd == "restart":
|
||||
return cmd_restart(args.services)
|
||||
elif cmd == "status":
|
||||
return cmd_status()
|
||||
elif cmd == "logs":
|
||||
return cmd_logs(args.service, args.follow)
|
||||
elif cmd == "seed":
|
||||
return cmd_seed(args.force, args.db)
|
||||
elif cmd == "lint":
|
||||
return cmd_lint()
|
||||
elif cmd == "typecheck":
|
||||
return cmd_typecheck()
|
||||
elif cmd == "build":
|
||||
return cmd_build()
|
||||
elif cmd == "clean":
|
||||
return cmd_clean(args.runtime, args.db, args.deps, args.all)
|
||||
elif cmd == "env":
|
||||
return cmd_env()
|
||||
elif cmd == "docker":
|
||||
return cmd_docker(args.action)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user