refactor: restructure mytoolkit with new subcommands and md-to-pdf improvements

- 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
This commit is contained in:
Zhengshou Lai
2026-05-04 17:19:32 +08:00
parent 38328c28d0
commit 3e4cf618a8
20 changed files with 1846 additions and 214 deletions
+403
View File
@@ -0,0 +1,403 @@
"""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("")