s
This commit is contained in:
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import importlib.util
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
@@ -25,6 +26,21 @@ def _local_ips() -> list[str]:
|
||||
return out[:4]
|
||||
|
||||
|
||||
def _node_major() -> tuple[int | None, str | None]:
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
return None, None
|
||||
try:
|
||||
raw = subprocess.check_output([node, "-v"], stderr=subprocess.STDOUT, text=True).strip()
|
||||
except Exception:
|
||||
return None, node
|
||||
try:
|
||||
major = int(raw.lstrip("v").split(".", 1)[0])
|
||||
except Exception:
|
||||
major = None
|
||||
return major, f"{node} ({raw})"
|
||||
|
||||
|
||||
def print_env_report(
|
||||
*,
|
||||
dev: bool,
|
||||
@@ -60,12 +76,25 @@ def print_env_report(
|
||||
print("│ ⚠ 未找到 ffplay(HDMI 全屏播放需要完整 ffmpeg 套件)")
|
||||
|
||||
if need_npm:
|
||||
major, node_desc = _node_major()
|
||||
npm = shutil.which("npm")
|
||||
docker = shutil.which("docker")
|
||||
if node_desc:
|
||||
if major is not None and major < 18:
|
||||
print(f"│ ⚠ Node: {node_desc}(Next.js 推荐 Node 18+;可自动尝试 Docker 兜底)")
|
||||
else:
|
||||
print(f"│ ✓ Node: {node_desc}")
|
||||
else:
|
||||
print("│ ✕ 未找到 Node.js(Next.js 构建/开发需要 Node 18+ 或 Docker 兜底)")
|
||||
ok = False
|
||||
if npm:
|
||||
print(f"│ ✓ npm: {npm}")
|
||||
else:
|
||||
print("│ ✕ 未找到 npm(构建/开发前端需要安装 Node.js)")
|
||||
ok = False
|
||||
print("│ ⚠ 未找到 npm(若有 Node 18+ 通常可用;否则请安装完整 Node 或启用 Docker)")
|
||||
if docker:
|
||||
print(f"│ ✓ Docker: {docker}")
|
||||
elif major is None or (major is not None and major < 18):
|
||||
print("│ ⚠ 无 Docker 时,Node 过低会依赖本机升级才能构建前端")
|
||||
|
||||
print("│")
|
||||
print("│ 同机端口提示(若冲突请在 config/runtime.env 修改 PORT):")
|
||||
|
||||
@@ -19,11 +19,14 @@
|
||||
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"):
|
||||
@@ -37,6 +40,9 @@ 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))
|
||||
@@ -60,54 +66,282 @@ def load_runtime_env() -> None:
|
||||
os.environ[k] = v
|
||||
|
||||
|
||||
def _which_npm() -> str | None:
|
||||
return shutil.which("npm")
|
||||
@dataclass(frozen=True)
|
||||
class NodeRuntime:
|
||||
node: Path
|
||||
major: int
|
||||
npm: Path | None
|
||||
source: str
|
||||
|
||||
|
||||
def _npm(args: list[str], *, cwd: Path, env: dict | None = None) -> int:
|
||||
full_env = {**os.environ, **(env or {})}
|
||||
npm = _which_npm()
|
||||
if not npm:
|
||||
return 127
|
||||
return subprocess.call([npm, *args], cwd=cwd, env=full_env)
|
||||
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 ensure_console_built(*, skip: bool) -> bool:
|
||||
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 _which_npm():
|
||||
print("[launch] 未检测到 npm,跳过前端构建;请先安装 Node.js 或手动在 web-console 执行 npm run build。")
|
||||
return OUT_INDEX.is_file()
|
||||
if OUT_INDEX.is_file():
|
||||
if not _console_needs_rebuild():
|
||||
return True
|
||||
print("[launch] 正在安装依赖并构建 Web 控制台(首次略慢)…")
|
||||
lock = CONSOLE / "package-lock.json"
|
||||
if lock.is_file():
|
||||
if _npm(["ci"], cwd=CONSOLE) != 0:
|
||||
print("[launch] npm ci 失败,尝试 npm install…")
|
||||
if _npm(["install"], cwd=CONSOLE) != 0:
|
||||
sys.exit(1)
|
||||
else:
|
||||
if _npm(["install"], cwd=CONSOLE) != 0:
|
||||
sys.exit(1)
|
||||
if _npm(["run", "build"], cwd=CONSOLE) != 0:
|
||||
if OUT_INDEX.is_file():
|
||||
print(
|
||||
"[launch] npm run build 失败,但检测到已有 web-console/out/index.html,"
|
||||
"将继续启动 API(静态页可能为旧构建;请修复构建后重启)。",
|
||||
)
|
||||
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(
|
||||
"[launch] npm run build 失败且缺少 web-console/out/index.html;"
|
||||
"无法提供控制台。请在 web-console 目录执行 npm run build 或检查 Node/内存。",
|
||||
f"[launch] {reason},但检测到已有 web-console/out/index.html,将继续启动。"
|
||||
" 如需刷新,请安装 Node 18+ 或 Docker 后重新构建。",
|
||||
)
|
||||
sys.exit(1)
|
||||
print("[launch] 前端构建完成。")
|
||||
return True
|
||||
return True
|
||||
print(
|
||||
f"[launch] {reason},且未能通过 Docker 兜底构建 Web 控制台。"
|
||||
" 请安装 Node 18+ / pnpm 或启用 Docker。",
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _terminate_process(p: subprocess.Popen | None) -> None:
|
||||
@@ -121,13 +355,79 @@ def _terminate_process(p: subprocess.Popen | None) -> None:
|
||||
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:
|
||||
if not _which_npm():
|
||||
print("[launch] 开发模式需要 Node.js/npm。仅起 API 请用: python scripts/launch.py --api-only --reload")
|
||||
sys.exit(1)
|
||||
api_url = f"http://127.0.0.1:{port}"
|
||||
env = os.environ.copy()
|
||||
env.setdefault("API_PROXY_TARGET", api_url)
|
||||
|
||||
api_cmd = [
|
||||
sys.executable,
|
||||
@@ -149,11 +449,13 @@ def run_dev(host: str, port: str, next_port: str) -> None:
|
||||
if api.poll() is not None:
|
||||
print("[launch] API 进程异常退出,请检查端口是否被占用。")
|
||||
sys.exit(api.returncode or 1)
|
||||
ui = subprocess.Popen(
|
||||
["npm", "run", "dev", "--", "--hostname", "0.0.0.0", "--port", next_port],
|
||||
cwd=CONSOLE,
|
||||
env=env,
|
||||
)
|
||||
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 + 端口")
|
||||
@@ -205,6 +507,7 @@ def main() -> None:
|
||||
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()
|
||||
@@ -220,9 +523,7 @@ def main() -> None:
|
||||
else:
|
||||
host = args.host or os.environ.get("HOST") or "0.0.0.0"
|
||||
|
||||
need_npm = args.dev or (
|
||||
not args.skip_build and not args.api_only and not OUT_INDEX.is_file()
|
||||
)
|
||||
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),
|
||||
@@ -236,6 +537,10 @@ def main() -> None:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user