585 lines
18 KiB
Python
585 lines
18 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
一键启动 Web 控制台:
|
||
生产:构建 web-console(若需要)+ 仅 uvicorn(单端口托管 API + 静态页)
|
||
开发:uvicorn + Next dev(默认绑定 0.0.0.0,局域网可访问)
|
||
|
||
自动加载 config/runtime.env(若存在,不覆盖已有环境变量)。
|
||
|
||
用法:
|
||
python scripts/launch.py
|
||
python scripts/launch.py --dev
|
||
python scripts/launch.py --port 8101
|
||
python scripts/launch.py --skip-build
|
||
python scripts/launch.py --no-env-check
|
||
|
||
环境变量: HOST, PORT, NEXT_DEV_PORT, WEB_STACK=api, API_PROXY_TARGET(开发)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
|
||
if hasattr(sys.stdout, "reconfigure"):
|
||
try:
|
||
sys.stdout.reconfigure(encoding="utf-8")
|
||
sys.stderr.reconfigure(encoding="utf-8")
|
||
except Exception:
|
||
pass
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||
CONSOLE = ROOT / "web-console"
|
||
OUT_INDEX = CONSOLE / "out" / "index.html"
|
||
OUT_STAMP = CONSOLE / "out" / ".build-stamp.json"
|
||
MIN_WEB_NODE_MAJOR = 18
|
||
DOCKER_NODE_IMAGE = os.environ.get("WEB_CONSOLE_NODE_IMAGE", "node:20-bookworm-slim")
|
||
|
||
sys.path.insert(0, str(ROOT))
|
||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||
import env_check # noqa: E402
|
||
from src.runtime_bootstrap import ensure_runtime_layout, raise_nofile_limit # noqa: E402
|
||
|
||
|
||
def load_runtime_env() -> None:
|
||
path = ROOT / "config" / "runtime.env"
|
||
if not path.is_file():
|
||
return
|
||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||
line = raw.strip()
|
||
if not line or line.startswith("#"):
|
||
continue
|
||
if "=" not in line:
|
||
continue
|
||
k, _, v = line.partition("=")
|
||
k, v = k.strip(), v.strip().strip('"').strip("'")
|
||
if k and k not in os.environ:
|
||
os.environ[k] = v
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class NodeRuntime:
|
||
node: Path
|
||
major: int
|
||
npm: Path | None
|
||
source: str
|
||
|
||
|
||
def _node_version_major(node_bin: str | Path) -> int | None:
|
||
try:
|
||
raw = subprocess.check_output([str(node_bin), "-v"], stderr=subprocess.STDOUT, text=True).strip()
|
||
except Exception:
|
||
return None
|
||
match = re.match(r"v(\d+)\.", raw)
|
||
if not match:
|
||
return None
|
||
return int(match.group(1))
|
||
|
||
|
||
def _candidate_node_bins() -> list[Path]:
|
||
seen: set[str] = set()
|
||
out: list[Path] = []
|
||
|
||
def add(path_like: str | Path | None) -> None:
|
||
if not path_like:
|
||
return
|
||
p = Path(path_like).expanduser()
|
||
if p.is_dir():
|
||
p = p / "node"
|
||
key = str(p.resolve() if p.exists() else p)
|
||
if key in seen or not p.exists():
|
||
return
|
||
seen.add(key)
|
||
out.append(p)
|
||
|
||
add(shutil.which("node"))
|
||
for name in ("node22", "node20", "node18", "node"):
|
||
add(shutil.which(name))
|
||
|
||
nvm_bin = os.environ.get("NVM_BIN")
|
||
if nvm_bin:
|
||
add(Path(nvm_bin) / "node")
|
||
|
||
for env_var in ("VOLTA_HOME", "MISE_INSTALL_PATH"):
|
||
base = os.environ.get(env_var)
|
||
if base:
|
||
add(Path(base) / "bin" / "node")
|
||
|
||
home = Path.home()
|
||
for p in [
|
||
home / ".nvm" / "versions" / "node",
|
||
home / ".asdf" / "installs" / "nodejs",
|
||
home / ".local" / "share" / "mise" / "installs" / "node",
|
||
Path("/usr/local/bin/node"),
|
||
Path("/opt/homebrew/bin/node"),
|
||
Path("/usr/bin/node"),
|
||
]:
|
||
if p.is_dir():
|
||
for c in sorted(p.glob("*/bin/node")):
|
||
add(c)
|
||
else:
|
||
add(p)
|
||
|
||
return out
|
||
|
||
|
||
def _pick_node_runtime(min_major: int = MIN_WEB_NODE_MAJOR) -> NodeRuntime | None:
|
||
best: NodeRuntime | None = None
|
||
for node in _candidate_node_bins():
|
||
major = _node_version_major(node)
|
||
if major is None:
|
||
continue
|
||
npm = node.with_name("npm")
|
||
if not npm.exists():
|
||
npm = shutil.which("npm", path=str(node.parent))
|
||
npm = Path(npm) if npm else None
|
||
runtime = NodeRuntime(node=node, major=major, npm=npm, source="local")
|
||
if major >= min_major:
|
||
return runtime
|
||
if best is None or major > best.major:
|
||
best = runtime
|
||
return best
|
||
|
||
|
||
def _runtime_env(runtime: NodeRuntime, extra: dict[str, str] | None = None) -> dict[str, str]:
|
||
env = os.environ.copy()
|
||
path = env.get("PATH", "")
|
||
node_dir = str(runtime.node.parent)
|
||
env["PATH"] = f"{node_dir}{os.pathsep}{path}" if path else node_dir
|
||
if runtime.npm is not None:
|
||
env["npm_config_script_shell"] = env.get("npm_config_script_shell", "/bin/sh")
|
||
if extra:
|
||
env.update(extra)
|
||
return env
|
||
|
||
|
||
def _console_sources_mtime() -> float:
|
||
roots = [
|
||
CONSOLE / "app",
|
||
CONSOLE / "components",
|
||
CONSOLE / "lib",
|
||
]
|
||
files = [
|
||
CONSOLE / "middleware.ts",
|
||
CONSOLE / "next.config.mjs",
|
||
CONSOLE / "package.json",
|
||
CONSOLE / "package-lock.json",
|
||
CONSOLE / "pnpm-lock.yaml",
|
||
CONSOLE / "postcss.config.mjs",
|
||
CONSOLE / "tailwind.config.ts",
|
||
CONSOLE / "tsconfig.json",
|
||
CONSOLE / "README.md",
|
||
]
|
||
latest = 0.0
|
||
for path in files:
|
||
if path.is_file():
|
||
latest = max(latest, path.stat().st_mtime)
|
||
for root in roots:
|
||
if not root.exists():
|
||
continue
|
||
for path in root.rglob("*"):
|
||
if not path.is_file():
|
||
continue
|
||
latest = max(latest, path.stat().st_mtime)
|
||
return latest
|
||
|
||
|
||
def _read_console_stamp() -> dict[str, object] | None:
|
||
if not OUT_STAMP.is_file():
|
||
return None
|
||
try:
|
||
return json.loads(OUT_STAMP.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _console_needs_rebuild() -> bool:
|
||
if not OUT_INDEX.is_file():
|
||
return True
|
||
stamp = _read_console_stamp()
|
||
if not stamp:
|
||
return True
|
||
try:
|
||
src_mtime = float(stamp.get("source_mtime") or 0.0)
|
||
except Exception:
|
||
return True
|
||
return _console_sources_mtime() > src_mtime + 0.5
|
||
|
||
|
||
def _write_console_stamp(*, toolchain: str, major: int | None = None) -> None:
|
||
try:
|
||
OUT_STAMP.parent.mkdir(parents=True, exist_ok=True)
|
||
payload = {
|
||
"built_at": time.time(),
|
||
"source_mtime": _console_sources_mtime(),
|
||
"toolchain": toolchain,
|
||
"node_major": major,
|
||
}
|
||
OUT_STAMP.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _docker_available() -> str | None:
|
||
return shutil.which("docker")
|
||
|
||
|
||
def _run_console_cmd(cmd: list[str], *, cwd: Path, env: dict[str, str]) -> int:
|
||
return subprocess.call(cmd, cwd=cwd, env=env)
|
||
|
||
|
||
def _npm_cmd(runtime: NodeRuntime | None = None) -> str:
|
||
if runtime and runtime.npm is not None:
|
||
return str(runtime.npm)
|
||
return shutil.which("npm") or "npm"
|
||
|
||
|
||
def _try_local_console_build() -> tuple[bool, str]:
|
||
runtime = _pick_node_runtime()
|
||
if runtime is None:
|
||
return False, "未找到可用 Node.js"
|
||
if runtime.major < MIN_WEB_NODE_MAJOR:
|
||
return False, f"当前 Node {runtime.major} 过低"
|
||
if runtime.npm is None:
|
||
return False, f"找到 Node {runtime.node},但未找到同目录 npm"
|
||
|
||
env = _runtime_env(runtime)
|
||
npm = _npm_cmd(runtime)
|
||
print(f"[launch] 使用本机 Node {runtime.major} 构建 Web 控制台: {runtime.node}")
|
||
|
||
rebuild = _console_needs_rebuild()
|
||
if not rebuild:
|
||
print("[launch] 控制台产物已是最新。")
|
||
return True, "up-to-date"
|
||
|
||
print("[launch] 正在构建 Web 控制台…")
|
||
for path in (CONSOLE / "node_modules", CONSOLE / ".next"):
|
||
if path.exists():
|
||
shutil.rmtree(path, ignore_errors=True)
|
||
lock = CONSOLE / "package-lock.json"
|
||
if lock.is_file():
|
||
rc = _run_console_cmd([npm, "ci"], cwd=CONSOLE, env=env)
|
||
if rc != 0:
|
||
print("[launch] npm ci 失败,尝试 npm install…")
|
||
rc = _run_console_cmd([npm, "install"], cwd=CONSOLE, env=env)
|
||
if rc != 0:
|
||
return False, "npm install 失败"
|
||
else:
|
||
rc = _run_console_cmd([npm, "install"], cwd=CONSOLE, env=env)
|
||
if rc != 0:
|
||
return False, "npm install 失败"
|
||
|
||
rc = _run_console_cmd([npm, "run", "build"], cwd=CONSOLE, env=env)
|
||
if rc != 0:
|
||
return False, "npm run build 失败"
|
||
_write_console_stamp(toolchain="local", major=runtime.major)
|
||
print("[launch] 前端构建完成。")
|
||
return True, "built"
|
||
|
||
|
||
def _docker_console_build() -> bool:
|
||
docker = _docker_available()
|
||
if not docker:
|
||
return False
|
||
print(f"[launch] 本机 Node 不足,改用 Docker 构建: {DOCKER_NODE_IMAGE}")
|
||
script = (
|
||
"set -e; "
|
||
"rm -rf /tmp/web-console; "
|
||
"mkdir -p /tmp/web-console; "
|
||
"cd /workspace/web-console; "
|
||
"tar --exclude=node_modules --exclude=.next --exclude=out -cf - . | tar -C /tmp/web-console -xf -; "
|
||
"cd /tmp/web-console; "
|
||
"rm -rf node_modules .next out; "
|
||
"export HOME=/tmp npm_config_cache=/tmp/npm-cache; "
|
||
"if [ -f package-lock.json ]; then npm ci || npm install; else npm install; fi; "
|
||
"test -x node_modules/.bin/next; "
|
||
"npm run build; "
|
||
"rm -rf /workspace/web-console/out; "
|
||
"cp -a out /workspace/web-console/out"
|
||
)
|
||
cmd = [
|
||
docker,
|
||
"run",
|
||
"--rm",
|
||
"-u",
|
||
f"{os.getuid()}:{os.getgid()}",
|
||
"-e",
|
||
"HOME=/tmp",
|
||
"-e",
|
||
"npm_config_cache=/tmp/npm-cache",
|
||
"-v",
|
||
f"{ROOT}:/workspace",
|
||
"-w",
|
||
"/workspace",
|
||
DOCKER_NODE_IMAGE,
|
||
"sh",
|
||
"-lc",
|
||
script,
|
||
]
|
||
rc = subprocess.call(cmd, cwd=ROOT)
|
||
if rc != 0:
|
||
return False
|
||
_write_console_stamp(toolchain="docker", major=20)
|
||
print("[launch] Docker 构建完成。")
|
||
return True
|
||
|
||
|
||
def ensure_console_built(*, skip: bool, strict: bool = False) -> bool:
|
||
"""返回是否已存在可托管的静态 index。"""
|
||
if skip:
|
||
return OUT_INDEX.is_file()
|
||
if not CONSOLE.is_dir():
|
||
print("[launch] 未找到 web-console,将仅使用根目录 index.html(若存在)。")
|
||
return False
|
||
if not _console_needs_rebuild():
|
||
return True
|
||
ok, reason = _try_local_console_build()
|
||
if ok:
|
||
return True
|
||
if _docker_console_build():
|
||
return True
|
||
if OUT_INDEX.is_file() and not strict:
|
||
print(
|
||
f"[launch] {reason},但检测到已有 web-console/out/index.html,将继续启动。"
|
||
" 如需刷新,请安装 Node 18+ 或 Docker 后重新构建。",
|
||
)
|
||
return True
|
||
print(
|
||
f"[launch] {reason},且未能通过 Docker 兜底构建 Web 控制台。"
|
||
" 请安装 Node 18+ / pnpm 或启用 Docker。",
|
||
)
|
||
sys.exit(1)
|
||
|
||
|
||
def _terminate_process(p: subprocess.Popen | None) -> None:
|
||
if p is None or p.poll() is not None:
|
||
return
|
||
p.terminate()
|
||
try:
|
||
p.wait(timeout=8)
|
||
except subprocess.TimeoutExpired:
|
||
p.kill()
|
||
p.wait(timeout=3)
|
||
|
||
|
||
def _console_dev_runtime() -> NodeRuntime | None:
|
||
runtime = _pick_node_runtime()
|
||
if runtime is None:
|
||
return None
|
||
if runtime.major < MIN_WEB_NODE_MAJOR or runtime.npm is None:
|
||
return None
|
||
return runtime
|
||
|
||
|
||
def _start_local_console_dev(*, host: str, port: str, next_port: str) -> subprocess.Popen | None:
|
||
runtime = _console_dev_runtime()
|
||
if runtime is None:
|
||
return None
|
||
env = _runtime_env(runtime, {"API_PROXY_TARGET": f"http://127.0.0.1:{port}"})
|
||
npm = _npm_cmd(runtime)
|
||
node_modules = CONSOLE / "node_modules"
|
||
if not node_modules.exists():
|
||
lock = CONSOLE / "package-lock.json"
|
||
print("[launch] 未检测到 web-console/node_modules,先安装依赖…")
|
||
install_cmd = [npm, "ci"] if lock.is_file() else [npm, "install"]
|
||
if _run_console_cmd(install_cmd, cwd=CONSOLE, env=env) != 0:
|
||
print("[launch] 依赖安装失败,准备尝试 Docker 兜底。")
|
||
return None
|
||
cmd = [npm, "run", "dev", "--", "--hostname", "0.0.0.0", "--port", next_port]
|
||
return subprocess.Popen(cmd, cwd=CONSOLE, env=env)
|
||
|
||
|
||
def _start_docker_console_dev(*, host: str, port: str, next_port: str) -> subprocess.Popen | None:
|
||
docker = _docker_available()
|
||
if not docker:
|
||
return None
|
||
proxy_target = f"http://host.docker.internal:{port}"
|
||
script = (
|
||
"set -e; "
|
||
"cd /workspace/web-console; "
|
||
"if [ ! -x node_modules/.bin/next ]; then rm -rf node_modules .next; fi; "
|
||
"if [ ! -d node_modules ]; then "
|
||
"if [ -f package-lock.json ]; then npm ci || npm install; else npm install; fi; "
|
||
"fi; "
|
||
"npm run dev -- --hostname 0.0.0.0 --port 3000"
|
||
)
|
||
cmd = [
|
||
docker,
|
||
"run",
|
||
"--rm",
|
||
"--add-host",
|
||
"host.docker.internal:host-gateway",
|
||
"-p",
|
||
f"{next_port}:3000",
|
||
"-e",
|
||
f"API_PROXY_TARGET={proxy_target}",
|
||
"-e",
|
||
"HOME=/tmp",
|
||
"-e",
|
||
"npm_config_cache=/tmp/npm-cache",
|
||
"-e",
|
||
"HOST=0.0.0.0",
|
||
"-e",
|
||
"PORT=3000",
|
||
"-e",
|
||
"WATCHPACK_POLLING=true",
|
||
"-e",
|
||
"CHOKIDAR_USEPOLLING=true",
|
||
"-e",
|
||
"NEXT_TELEMETRY_DISABLED=1",
|
||
"-v",
|
||
f"{ROOT}:/workspace",
|
||
"-w",
|
||
"/workspace",
|
||
DOCKER_NODE_IMAGE,
|
||
"sh",
|
||
"-lc",
|
||
script,
|
||
]
|
||
print(f"[launch] 本机 Node 不足,改用 Docker 开发容器: {DOCKER_NODE_IMAGE}")
|
||
return subprocess.Popen(cmd, cwd=ROOT)
|
||
|
||
|
||
def run_dev(host: str, port: str, next_port: str) -> None:
|
||
api_url = f"http://127.0.0.1:{port}"
|
||
|
||
api_cmd = [
|
||
sys.executable,
|
||
"-m",
|
||
"uvicorn",
|
||
"web:app",
|
||
"--host",
|
||
host,
|
||
"--port",
|
||
port,
|
||
"--reload",
|
||
]
|
||
os.chdir(ROOT)
|
||
api: subprocess.Popen | None = None
|
||
ui: subprocess.Popen | None = None
|
||
try:
|
||
api = subprocess.Popen(api_cmd, cwd=ROOT)
|
||
time.sleep(0.6)
|
||
if api.poll() is not None:
|
||
print("[launch] API 进程异常退出,请检查端口是否被占用。")
|
||
sys.exit(api.returncode or 1)
|
||
ui = _start_local_console_dev(host=host, port=port, next_port=next_port)
|
||
if ui is None:
|
||
ui = _start_docker_console_dev(host=host, port=port, next_port=next_port)
|
||
if ui is None:
|
||
print("[launch] 未找到可用的 Node 18+ 或 Docker,无法启动 Next dev。")
|
||
print("[launch] 仅起 API 请用: python scripts/launch.py --api-only --reload")
|
||
sys.exit(1)
|
||
print()
|
||
print(" —— 开发模式(局域网可访问)——")
|
||
print(f" 控制台界面: http://0.0.0.0:{next_port} 或用本机 IP + 端口")
|
||
print(f" API: http://{host}:{port}/ (Next 已代理同名接口到 {api_url})")
|
||
print(" 按 Ctrl+C 可同时结束前后端")
|
||
if host == "0.0.0.0":
|
||
print(" 提示: 绑定 0.0.0.0 时请勿把管理端口暴露到公网")
|
||
print()
|
||
while True:
|
||
time.sleep(0.5)
|
||
if api.poll() is not None:
|
||
print("[launch] API 已退出")
|
||
break
|
||
if ui and ui.poll() is not None:
|
||
print("[launch] Next 已退出")
|
||
break
|
||
except KeyboardInterrupt:
|
||
print("\n[launch] 正在关闭…")
|
||
finally:
|
||
_terminate_process(ui)
|
||
_terminate_process(api)
|
||
|
||
|
||
def run_api_only(host: str, port: str, reload: bool) -> None:
|
||
os.chdir(ROOT)
|
||
cmd = [sys.executable, "-m", "uvicorn", "web:app", "--host", host, "--port", port]
|
||
if reload:
|
||
cmd.append("--reload")
|
||
os.execvp(sys.executable, cmd)
|
||
|
||
|
||
def run_prod(host: str, port: str, skip_build: bool, reload: bool) -> None:
|
||
ensure_console_built(skip=skip_build)
|
||
run_api_only(host, port, reload)
|
||
|
||
|
||
def main() -> None:
|
||
load_runtime_env()
|
||
raise_nofile_limit()
|
||
runtime_issues = ensure_runtime_layout(ROOT)
|
||
if runtime_issues:
|
||
print("[launch] warning: some runtime directories are not writable by the current user:", file=sys.stderr)
|
||
for issue in runtime_issues:
|
||
print(f"[launch] - {issue}", file=sys.stderr)
|
||
print(
|
||
"[launch] hint: fix deployment ownership for runtime dirs such as config/logs/backup_config/.pm2/.process_runner/downloads/backup",
|
||
file=sys.stderr,
|
||
)
|
||
|
||
default_port = os.environ.get("PORT", "8001")
|
||
ap = argparse.ArgumentParser(description="直播控制台一键启动")
|
||
ap.add_argument("--dev", action="store_true", help="开发:API + Next(需 Node)")
|
||
ap.add_argument(
|
||
"--api-only",
|
||
action="store_true",
|
||
help="仅 uvicorn,不构建、不启动 Next",
|
||
)
|
||
ap.add_argument("--host", default=None, help="监听地址(默认生产 0.0.0.0,可经 HOST 环境变量)")
|
||
ap.add_argument("--port", default=None, help="端口(默认 PORT 或 8001)")
|
||
ap.add_argument("--skip-build", action="store_true", help="跳过前端构建检测")
|
||
ap.add_argument("--build-only", action="store_true", help="只构建 web-console,不启动 API")
|
||
ap.add_argument("--reload", action="store_true", help="uvicorn --reload(生产慎用)")
|
||
ap.add_argument("--no-env-check", action="store_true", help="跳过环境分析")
|
||
args = ap.parse_args()
|
||
|
||
if os.environ.get("WEB_STACK", "").lower() == "api":
|
||
args.api_only = True
|
||
|
||
port = str(args.port if args.port is not None else default_port)
|
||
next_port = os.environ.get("NEXT_DEV_PORT", "3000")
|
||
|
||
if args.dev:
|
||
host = args.host or os.environ.get("HOST") or "0.0.0.0"
|
||
else:
|
||
host = args.host or os.environ.get("HOST") or "0.0.0.0"
|
||
|
||
need_npm = bool(args.dev or args.build_only or (not args.skip_build and not args.api_only and _console_needs_rebuild()))
|
||
if not args.no_env_check:
|
||
env_check.print_env_report(
|
||
dev=bool(args.dev),
|
||
need_npm=need_npm,
|
||
host=host,
|
||
port=port,
|
||
next_port=next_port,
|
||
)
|
||
|
||
if args.dev:
|
||
run_dev(host, port, next_port)
|
||
return
|
||
|
||
if args.build_only:
|
||
ensure_console_built(skip=False, strict=True)
|
||
return
|
||
|
||
if args.api_only:
|
||
run_api_only(host, port, args.reload)
|
||
return
|
||
|
||
run_prod(host, port, skip_build=args.skip_build, reload=args.reload)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|