- Add new subcommands: convert, preflight, server, templates, webpage - Migrate config from bin/config.json to ~/.config/mytoolkit - Fix expand_bookmarks to modify writer objects instead of reader - Improve md_to_pdf with CJK bookmark support - Update README and project metadata
404 lines
13 KiB
Python
404 lines
13 KiB
Python
"""Server management for workspace projects."""
|
|
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
import time
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Literal, Optional
|
|
|
|
import click
|
|
|
|
|
|
Mode = Literal["dev", "serve"]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Service:
|
|
name: str
|
|
serve_port: int
|
|
dev_port: Optional[int] = None
|
|
description: str = ""
|
|
project_dir: Optional[Path] = None
|
|
serve_cmd: Optional[list[str]] = None
|
|
dev_cmd: Optional[list[str]] = None
|
|
|
|
@property
|
|
def manageable(self) -> bool:
|
|
return self.project_dir is not None and self.serve_cmd is not None
|
|
|
|
@property
|
|
def has_dev(self) -> bool:
|
|
return self.dev_port is not None and self.dev_cmd is not None
|
|
|
|
def port_for(self, dev: bool) -> Optional[int]:
|
|
return self.dev_port if dev else self.serve_port
|
|
|
|
def cmd_for(self, dev: bool) -> Optional[list[str]]:
|
|
return self.dev_cmd if dev else self.serve_cmd
|
|
|
|
|
|
KNOWN_SERVICES: list[Service] = [
|
|
Service("metabot-api", 9100, description="MetaBot HTTP API"),
|
|
Service("metabot-memory", 8100, description="MetaBot MetaMemory"),
|
|
Service(
|
|
"mywebpage",
|
|
serve_port=3010,
|
|
dev_port=3000,
|
|
description="APAAM Lab website (Docusaurus)",
|
|
project_dir=Path.home() / "Documents" / "myWork" / "sysu" / "04-学术积累" / "mywebpage",
|
|
serve_cmd=["npm", "run", "serve"],
|
|
dev_cmd=["npm", "run", "dev"],
|
|
),
|
|
Service(
|
|
"apaam-webpage",
|
|
serve_port=3011,
|
|
dev_port=3001,
|
|
description="Phynexis docs (Docusaurus)",
|
|
project_dir=Path.home() / "Documents" / "myResearch" / "myProjects" / "apaam" / "repo" / "webpage",
|
|
serve_cmd=["npm", "run", "serve"],
|
|
dev_cmd=["npm", "run", "dev"],
|
|
),
|
|
Service(
|
|
"xiaohe-webpage",
|
|
serve_port=3012,
|
|
dev_port=3002,
|
|
description="Xiaohe company website (Docusaurus)",
|
|
project_dir=Path.home() / "Documents" / "myResearch" / "myProjects" / "apaam" / "repo" / "xiaohe-webpage",
|
|
serve_cmd=["npm", "run", "serve"],
|
|
dev_cmd=["npm", "run", "dev"],
|
|
),
|
|
Service("myslides", 3456, description="Slidev dev server"),
|
|
]
|
|
|
|
_BY_NAME: dict[str, Service] = {s.name: s for s in KNOWN_SERVICES}
|
|
_ALL_NAMES = list(_BY_NAME.keys())
|
|
_MANAGEABLE_NAMES = [s.name for s in KNOWN_SERVICES if s.manageable]
|
|
|
|
|
|
def manageable_services() -> list[Service]:
|
|
return [s for s in KNOWN_SERVICES if s.manageable]
|
|
|
|
|
|
def get_service(name: str) -> Service:
|
|
return _BY_NAME[name]
|
|
|
|
|
|
def no_color_env() -> dict:
|
|
env = os.environ.copy()
|
|
env["NO_COLOR"] = "1"
|
|
return env
|
|
|
|
|
|
def _listening_map() -> dict[int, dict]:
|
|
"""Single batched lsof query for all TCP listening sockets, keyed by port."""
|
|
try:
|
|
result = subprocess.run(
|
|
["lsof", "-nP", "-iTCP", "-sTCP:LISTEN"],
|
|
capture_output=True,
|
|
text=True,
|
|
errors="replace",
|
|
timeout=5,
|
|
)
|
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
return {}
|
|
if result.returncode != 0 or not result.stdout.strip():
|
|
return {}
|
|
|
|
by_port: dict[int, dict] = {}
|
|
for line in result.stdout.strip().split("\n")[1:]:
|
|
parts = line.split()
|
|
if len(parts) < 9:
|
|
continue
|
|
listen = next((t for t in reversed(parts) if ":" in t and not t.startswith("(")), None)
|
|
if not listen:
|
|
continue
|
|
try:
|
|
port = int(listen.rsplit(":", 1)[1])
|
|
except (ValueError, IndexError):
|
|
continue
|
|
by_port[port] = {
|
|
"command": parts[0],
|
|
"pid": parts[1],
|
|
"user": parts[2],
|
|
"listen": listen,
|
|
}
|
|
return by_port
|
|
|
|
|
|
def _port_status(port: int) -> Optional[dict]:
|
|
return _listening_map().get(port)
|
|
|
|
|
|
def _pid_command(pid: str) -> str:
|
|
try:
|
|
result = subprocess.run(
|
|
["ps", "-p", pid, "-o", "comm="],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=2,
|
|
)
|
|
if result.returncode == 0:
|
|
return result.stdout.strip()
|
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
pass
|
|
return ""
|
|
|
|
|
|
def _resolve(svc: Service, dev: bool) -> tuple[Optional[int], Optional[list[str]], Mode]:
|
|
return svc.port_for(dev), svc.cmd_for(dev), "dev" if dev else "serve"
|
|
|
|
|
|
@click.group(name="server")
|
|
def server_cmd():
|
|
"""Manage workspace dev/serve servers."""
|
|
pass
|
|
|
|
|
|
def _print_row(name: str, mode: str, port: int, desc: str, info: Optional[dict]) -> bool:
|
|
if info:
|
|
pid = info.get("pid", "?")
|
|
click.secho(
|
|
f"{name:<16} {mode:<6} {port:<6} {'running':<10} {pid:<8} {desc}",
|
|
fg="green",
|
|
)
|
|
return True
|
|
click.secho(
|
|
f"{name:<16} {mode:<6} {port:<6} {'stopped':<10} {'-':<8} {desc}",
|
|
fg="black",
|
|
)
|
|
return False
|
|
|
|
|
|
@server_cmd.command("list")
|
|
def server_list():
|
|
"""List all known services (serve + dev modes) and their status."""
|
|
click.echo("")
|
|
click.secho(
|
|
f"{'Service':<16} {'Mode':<6} {'Port':<6} {'Status':<10} {'PID':<8} {'Description'}",
|
|
fg="bright_blue",
|
|
bold=True,
|
|
)
|
|
click.echo("-" * 90)
|
|
|
|
listening = _listening_map()
|
|
running = 0
|
|
total = 0
|
|
for svc in KNOWN_SERVICES:
|
|
primary_label = "serve" if svc.serve_cmd is not None else "-"
|
|
if _print_row(svc.name, primary_label, svc.serve_port, svc.description, listening.get(svc.serve_port)):
|
|
running += 1
|
|
total += 1
|
|
|
|
if svc.has_dev:
|
|
assert svc.dev_port is not None
|
|
if _print_row("", "dev", svc.dev_port, "(hot reload)", listening.get(svc.dev_port)):
|
|
running += 1
|
|
total += 1
|
|
|
|
click.echo("-" * 90)
|
|
click.echo(f"Total: {total} ports, {running} running, {total - running} stopped")
|
|
click.echo("")
|
|
|
|
|
|
@server_cmd.command("check")
|
|
@click.argument("port", type=int)
|
|
def server_check(port):
|
|
"""Check if a specific port is open."""
|
|
info = _port_status(port)
|
|
if info:
|
|
cmd = _pid_command(info.get("pid", ""))
|
|
click.secho(
|
|
f"Port {port} is open (PID {info.get('pid', '?')}, {cmd or info.get('command', '')})",
|
|
fg="green",
|
|
)
|
|
else:
|
|
click.secho(f"Port {port} is not listening", fg="red")
|
|
|
|
|
|
@server_cmd.command("scan")
|
|
@click.argument("start", type=int, default=3000)
|
|
@click.argument("end", type=int, default=4000)
|
|
def server_scan(start, end):
|
|
"""Scan a port range for listening services."""
|
|
click.echo(f"Scanning ports {start}-{end} ...")
|
|
listening = _listening_map()
|
|
found = 0
|
|
for port in range(start, end + 1):
|
|
info = listening.get(port)
|
|
if info:
|
|
cmd = _pid_command(info.get("pid", ""))
|
|
click.secho(
|
|
f" {port:<5} {cmd or info.get('command', '')} (PID {info.get('pid', '?')})",
|
|
fg="green",
|
|
)
|
|
found += 1
|
|
if found == 0:
|
|
click.echo("No listening ports found.")
|
|
else:
|
|
click.echo(f"\nFound {found} open port(s).")
|
|
|
|
|
|
@server_cmd.command("start")
|
|
@click.argument("name", type=click.Choice(_MANAGEABLE_NAMES, case_sensitive=False))
|
|
@click.option("--dev", is_flag=True, help="Start hot-reload dev server on the dev port (default: serve)")
|
|
@click.option("--rebuild", is_flag=True, help="Force npm run build before serve (no effect with --dev)")
|
|
def server_start(name, dev, rebuild):
|
|
"""Start a server in the background.
|
|
|
|
By default, serve mode is used (production build) on the serve port.
|
|
Use --dev to start the hot-reload dev server on the dev port instead.
|
|
"""
|
|
svc = get_service(name)
|
|
port, cmd, mode = _resolve(svc, dev)
|
|
|
|
if port is None or cmd is None:
|
|
click.secho(f"{name} has no {mode} mode configured", fg="red")
|
|
return
|
|
|
|
assert svc.project_dir is not None # _MANAGEABLE_NAMES choice guarantees this
|
|
|
|
if _port_status(port) is not None:
|
|
click.secho(f"{name} ({mode}) is already running on port {port}", fg="yellow")
|
|
return
|
|
|
|
if not svc.project_dir.exists():
|
|
click.secho(f"Project directory not found: {svc.project_dir}", fg="red")
|
|
return
|
|
|
|
env = no_color_env()
|
|
|
|
if not dev:
|
|
build_dir = svc.project_dir / "build"
|
|
if rebuild or not build_dir.exists():
|
|
reason = "forced rebuild" if rebuild else "build directory missing"
|
|
click.secho(f"Running npm run build ({reason}) ...", fg="cyan")
|
|
build_proc = subprocess.run(
|
|
["npm", "run", "build"],
|
|
cwd=svc.project_dir,
|
|
env=env,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if build_proc.returncode != 0:
|
|
click.secho(f"Build failed for {name}", fg="red")
|
|
click.echo(build_proc.stderr[-500:] if build_proc.stderr else "")
|
|
return
|
|
|
|
log_file = svc.project_dir / "logs" / f"{svc.name}-{mode}.log"
|
|
log_file.parent.mkdir(exist_ok=True)
|
|
full_cmd = ["nohup"] + cmd
|
|
click.secho(f"Starting {name} ({svc.description}) on port {port} ({mode}) ...", fg="cyan")
|
|
|
|
with open(log_file, "a") as log:
|
|
proc = subprocess.Popen(
|
|
full_cmd,
|
|
cwd=svc.project_dir,
|
|
stdout=log,
|
|
stderr=subprocess.STDOUT,
|
|
start_new_session=True,
|
|
env=env,
|
|
)
|
|
|
|
max_wait = 20 if dev else 30
|
|
for _ in range(max_wait * 2):
|
|
time.sleep(0.5)
|
|
if _port_status(port) is not None:
|
|
click.secho(f"{name} ({mode}) started successfully (PID {proc.pid})", fg="green")
|
|
return
|
|
|
|
click.secho(f"{name} may have failed to start (check {log_file})", fg="yellow")
|
|
|
|
|
|
def _kill_port(name: str, mode: Mode, port: int):
|
|
info = _port_status(port)
|
|
if info is None:
|
|
click.secho(f"{name} ({mode}) is not running on port {port}", fg="yellow")
|
|
return
|
|
|
|
pid = info["pid"]
|
|
try:
|
|
os.kill(int(pid), signal.SIGTERM)
|
|
click.secho(f"Sent SIGTERM to {name} ({mode}, PID {pid})", fg="green")
|
|
except ProcessLookupError:
|
|
click.secho(f"Process {pid} not found", fg="yellow")
|
|
return
|
|
|
|
for _ in range(10):
|
|
time.sleep(0.5)
|
|
if _port_status(port) is None:
|
|
click.secho(f"{name} ({mode}) stopped", fg="green")
|
|
return
|
|
|
|
try:
|
|
os.kill(int(pid), signal.SIGKILL)
|
|
click.secho(f"Sent SIGKILL to {name} ({mode}, PID {pid})", fg="red")
|
|
except ProcessLookupError:
|
|
pass
|
|
|
|
|
|
@server_cmd.command("stop")
|
|
@click.argument("name", type=click.Choice(_MANAGEABLE_NAMES, case_sensitive=False))
|
|
@click.option("--dev", is_flag=True, help="Stop the dev-port instance instead of serve")
|
|
@click.option("--all", "all_modes", is_flag=True, help="Stop both dev and serve instances")
|
|
def server_stop(name, dev, all_modes):
|
|
"""Stop a running server."""
|
|
svc = get_service(name)
|
|
|
|
if all_modes:
|
|
targets: list[tuple[Mode, int]] = [("serve", svc.serve_port)]
|
|
if svc.has_dev:
|
|
assert svc.dev_port is not None
|
|
targets.append(("dev", svc.dev_port))
|
|
else:
|
|
port, _, mode = _resolve(svc, dev)
|
|
if port is None:
|
|
click.secho(f"{name} has no {mode} mode configured", fg="red")
|
|
return
|
|
targets = [(mode, port)]
|
|
|
|
for target_mode, target_port in targets:
|
|
_kill_port(name, target_mode, target_port)
|
|
|
|
|
|
@server_cmd.command("restart")
|
|
@click.argument("name", type=click.Choice(_MANAGEABLE_NAMES, case_sensitive=False))
|
|
@click.option("--dev", is_flag=True, help="Restart the dev instance (default: serve)")
|
|
@click.option("--rebuild", is_flag=True, help="Force npm run build before serve (no effect with --dev)")
|
|
def server_restart(name, dev, rebuild):
|
|
"""Restart a server."""
|
|
ctx = click.get_current_context()
|
|
ctx.invoke(server_stop, name=name, dev=dev, all_modes=False)
|
|
ctx.invoke(server_start, name=name, dev=dev, rebuild=rebuild)
|
|
|
|
|
|
@server_cmd.command("status")
|
|
@click.argument("name", type=click.Choice(_ALL_NAMES, case_sensitive=False))
|
|
def server_status(name):
|
|
"""Show detailed status of a service (both serve and dev ports)."""
|
|
svc = get_service(name)
|
|
|
|
click.echo("")
|
|
click.secho(f"Service: {svc.name}", bold=True)
|
|
click.echo(f"Desc: {svc.description}")
|
|
if svc.project_dir:
|
|
click.echo(f"Project: {svc.project_dir}")
|
|
|
|
def _show(label: str, port: Optional[int]):
|
|
if port is None:
|
|
return
|
|
info = _port_status(port)
|
|
if info:
|
|
cmd = _pid_command(info.get("pid", ""))
|
|
click.secho(
|
|
f"{label:<8} {port} running (PID {info.get('pid', '?')}, {cmd or info.get('command', '')})",
|
|
fg="green",
|
|
)
|
|
else:
|
|
click.secho(f"{label:<8} {port} stopped", fg="black")
|
|
|
|
_show("Serve:", svc.serve_port)
|
|
_show("Dev:", svc.dev_port)
|
|
click.echo("")
|