Files
gitlab-instance-0a899031_sh/d2ypp2/scripts/launch.py
root 088a5a6429 s
2026-05-18 11:04:52 +00:00

585 lines
18 KiB
Python
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
# -*- 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()