3149770887
Remove Gate scout auto-seeding and the manual hub login button while keeping Gate login and instance SSO. Add password change and database backup/restore, plus deploy scripts with env auto-generation. Co-authored-by: Cursor <cursoragent@cursor.com>
218 lines
5.7 KiB
Python
218 lines
5.7 KiB
Python
#!/usr/bin/env python3
|
|
"""LocalNav 一键部署:环境检测、生成 .env、安装依赖。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import platform
|
|
import secrets
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
ENV_EXAMPLE = ROOT / ".env.example"
|
|
ENV_FILE = ROOT / ".env"
|
|
VENV_DIR = ROOT / ".venv"
|
|
REQUIREMENTS = ROOT / "requirements.txt"
|
|
|
|
MIN_PYTHON = (3, 10)
|
|
|
|
DEFAULTS = {
|
|
"NAV_ADMIN_USERNAME": "admin",
|
|
"NAV_ADMIN_PASSWORD": "admin123",
|
|
"NAV_HUB_AUTO_LOGIN": "1",
|
|
"NAV_COOKIES_INSECURE_HTTP": "1",
|
|
}
|
|
|
|
|
|
def _ok(msg: str) -> None:
|
|
print(f"[OK] {msg}")
|
|
|
|
|
|
def _warn(msg: str) -> None:
|
|
print(f"[WARN] {msg}")
|
|
|
|
|
|
def _fail(msg: str) -> None:
|
|
print(f"[FAIL] {msg}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def check_python() -> str:
|
|
ver = sys.version_info
|
|
if ver < MIN_PYTHON:
|
|
_fail(f"需要 Python {MIN_PYTHON[0]}.{MIN_PYTHON[1]}+,当前 {ver.major}.{ver.minor}")
|
|
_ok(f"Python {ver.major}.{ver.minor}.{ver.micro}")
|
|
return sys.executable
|
|
|
|
|
|
def check_pip(py_exe: str) -> None:
|
|
try:
|
|
subprocess.run(
|
|
[py_exe, "-m", "pip", "--version"],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
_ok("pip 可用")
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
_fail("pip 不可用,请先安装 pip")
|
|
|
|
|
|
def optional_tools() -> None:
|
|
for name in ("git", "pm2"):
|
|
if shutil.which(name):
|
|
_ok(f"可选工具 {name} 已安装")
|
|
else:
|
|
_warn(f"未检测到 {name}(可选)")
|
|
|
|
|
|
def read_env_lines(path: Path) -> list[str]:
|
|
if not path.is_file():
|
|
return []
|
|
return path.read_text(encoding="utf-8-sig").splitlines()
|
|
|
|
|
|
def parse_env_keys(lines: list[str]) -> dict[str, str]:
|
|
out: dict[str, str] = {}
|
|
for line in lines:
|
|
s = line.strip()
|
|
if not s or s.startswith("#") or "=" not in s:
|
|
continue
|
|
key, _, val = s.partition("=")
|
|
out[key.strip()] = val.strip()
|
|
return out
|
|
|
|
|
|
def write_env(lines: list[str], updates: dict[str, str]) -> None:
|
|
existing = parse_env_keys(lines)
|
|
merged = {**existing, **updates}
|
|
out_lines: list[str] = []
|
|
written: set[str] = set()
|
|
|
|
for line in lines:
|
|
s = line.strip()
|
|
if not s or s.startswith("#") or "=" not in s:
|
|
out_lines.append(line)
|
|
continue
|
|
key, _, _ = s.partition("=")
|
|
key = key.strip()
|
|
if key in merged and key not in written:
|
|
out_lines.append(f"{key}={merged[key]}")
|
|
written.add(key)
|
|
else:
|
|
out_lines.append(line)
|
|
|
|
for key, val in merged.items():
|
|
if key not in written:
|
|
out_lines.append(f"{key}={val}")
|
|
ENV_FILE.write_text("\n".join(out_lines) + "\n", encoding="utf-8")
|
|
|
|
|
|
def ensure_env() -> None:
|
|
if not ENV_FILE.is_file():
|
|
if ENV_EXAMPLE.is_file():
|
|
shutil.copy2(ENV_EXAMPLE, ENV_FILE)
|
|
_ok("已从 .env.example 创建 .env")
|
|
else:
|
|
ENV_FILE.write_text("", encoding="utf-8")
|
|
_ok("已创建空 .env")
|
|
|
|
lines = read_env_lines(ENV_FILE)
|
|
parsed = parse_env_keys(lines)
|
|
updates: dict[str, str] = {}
|
|
|
|
secret = parsed.get("NAV_SECRET_KEY", "").strip()
|
|
if not secret:
|
|
updates["NAV_SECRET_KEY"] = secrets.token_hex(32)
|
|
_ok("已自动生成 NAV_SECRET_KEY")
|
|
|
|
for key, val in DEFAULTS.items():
|
|
if not parsed.get(key, "").strip():
|
|
updates[key] = val
|
|
|
|
if updates:
|
|
write_env(lines, updates)
|
|
_ok(f"已写入 .env 配置项:{', '.join(updates.keys())}")
|
|
else:
|
|
_ok(".env 已完整,未覆盖现有配置")
|
|
|
|
|
|
def venv_python() -> str:
|
|
if platform.system() == "Windows":
|
|
py = VENV_DIR / "Scripts" / "python.exe"
|
|
else:
|
|
py = VENV_DIR / "bin" / "python"
|
|
return str(py)
|
|
|
|
|
|
def ensure_venv(base_py: str) -> str:
|
|
if not VENV_DIR.is_dir():
|
|
subprocess.run([base_py, "-m", "venv", str(VENV_DIR)], check=True)
|
|
_ok(f"已创建虚拟环境 {VENV_DIR}")
|
|
else:
|
|
_ok("虚拟环境已存在")
|
|
return venv_python()
|
|
|
|
|
|
def install_requirements(py_exe: str) -> None:
|
|
cmd = [py_exe, "-m", "pip", "install", "-r", str(REQUIREMENTS)]
|
|
try:
|
|
subprocess.run(cmd, check=True, cwd=ROOT)
|
|
except subprocess.CalledProcessError:
|
|
_warn("默认 pip 源失败,尝试 https://pypi.org/simple")
|
|
subprocess.run([*cmd, "-i", "https://pypi.org/simple"], check=True, cwd=ROOT)
|
|
_ok("依赖安装完成")
|
|
|
|
|
|
def smoke_test(py_exe: str) -> None:
|
|
subprocess.run(
|
|
[
|
|
py_exe,
|
|
"-c",
|
|
"from app import create_app; create_app(); print('app import ok')",
|
|
],
|
|
check=True,
|
|
cwd=ROOT,
|
|
)
|
|
_ok("应用启动检查通过")
|
|
|
|
|
|
def print_summary() -> None:
|
|
port = os.environ.get("NAV_PORT", "5070")
|
|
print()
|
|
print("=" * 50)
|
|
print("部署完成")
|
|
print(f" 访问地址: http://0.0.0.0:{port}")
|
|
print(" 默认账号: admin / admin123")
|
|
print(" 生产环境请尽快在「系统设置」中修改密码")
|
|
print()
|
|
print("启动方式(任选其一):")
|
|
if platform.system() == "Windows":
|
|
print(f" {venv_python()} app.py")
|
|
else:
|
|
print(f" {venv_python()} app.py")
|
|
print(" pm2 start ecosystem.config.cjs")
|
|
print("=" * 50)
|
|
|
|
|
|
def main() -> None:
|
|
print(f"LocalNav 一键部署 · {platform.system()} {platform.release()}")
|
|
print(f"项目目录: {ROOT}")
|
|
print()
|
|
|
|
base_py = check_python()
|
|
check_pip(base_py)
|
|
optional_tools()
|
|
ensure_env()
|
|
py_exe = ensure_venv(base_py)
|
|
install_requirements(py_exe)
|
|
smoke_test(py_exe)
|
|
print_summary()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|