#!/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 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 控制台…") 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; " "cd /workspace/web-console; " "if [ ! -d node_modules ]; then npm ci || npm install; fi; " "npm run build" ) cmd = [ docker, "run", "--rm", "-u", f"{os.getuid()}:{os.getgid()}", "-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 [ ! -d node_modules ]; then npm ci || npm install; 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", "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() 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()