Files
hci_work/cli.py
T
2026-06-06 23:54:11 +08:00

594 lines
18 KiB
Python
Executable File
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
"""宿舍报修系统 —— 统一项目管理 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())