diff --git a/.env.example b/.env.example index 9c25dad..228f4f8 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ # 复制本文件为 .env 后按需修改(.env 勿提交到 Git) # 与 app.py 同目录;程序启动时自动加载。 -# 也可运行 python scripts/install.py 自动克隆并部署(无需预先 git clone) +# 也可运行 python scripts/navctl.py 进入交互菜单(无需预先 git clone) # 必填(长期运行):随机字符串,用于会话与 CSRF。 # 一键部署脚本会自动生成;手动生成:openssl rand -hex 32 diff --git a/README.md b/README.md index 2a19a6e..2829968 100644 --- a/README.md +++ b/README.md @@ -10,44 +10,57 @@ Flask + SQLite 的局域网导航聚合:左侧分组与服务列表,右侧 i ## 一键部署(无需预先克隆) -在**空目录或新服务器**上,只需运行安装脚本,按提示输入 Git 仓库地址即可自动克隆并完成部署: +运行安装脚本后会进入**交互菜单**: + +``` +================================================== + LocalNav 部署管理 +================================================== + 安装目录: /opt/LocalNav + 服务状态: pm2 online +-------------------------------------------------- + 1 一键部署安装 + 2 一键卸载 + 3 更新 + 0 退出 +-------------------------------------------------- +请选择: +``` ```bash # Linux / macOS(推荐:一条命令,无需先 git clone) curl -fsSL https://git.bz121.com/dekun/LocalNav/raw/main/scripts/install.sh | bash -# 或本地已有 install 脚本时 +# 或本地执行 bash scripts/install.sh -python scripts/install.py +python scripts/navctl.py # Windows PowerShell .\scripts\install.ps1 ``` -运行后会提示: +**菜单功能:** -1. **Git 仓库地址**(默认 `https://git.bz121.com/dekun/LocalNav.git`) -2. **安装目录**(Linux 默认 `/opt/LocalNav`,Windows 默认 `./LocalNav`) +| 选项 | 说明 | +|------|------| +| 1 安装 | 输入仓库地址 → 自动 `git clone` → 安装依赖 → 生成 `.env` → pm2 启动 | +| 2 卸载 | 停止 pm2/systemd 服务;可选删除整个安装目录(含数据库) | +| 3 更新 | `git pull` → 重新部署 → 自动重启服务 | +| 0 退出 | 退出菜单 | + +非交互命令: + +```bash +python scripts/navctl.py install -y +python scripts/navctl.py update --dir /opt/LocalNav +python scripts/navctl.py uninstall --dir /opt/LocalNav +``` 随后自动:`git clone` → 创建虚拟环境 → 安装依赖 → 生成 `.env` 与 `NAV_SECRET_KEY` → **自动 pm2 启动**(若已安装 pm2)。 -非交互安装(使用默认值): - -```bash -python scripts/install.py -y -# 或指定参数 -python scripts/install.py --repo https://git.bz121.com/dekun/LocalNav.git --dir /opt/LocalNav -``` - **默认登录:** `admin` / `admin123`(生产环境请尽快在「系统设置」中修改密码) -**已安装后的更新**(在项目目录内): - -```bash -cd /opt/LocalNav -git pull -python scripts/deploy.py # 或 bash scripts/deploy.sh -``` +**已安装后的更新:** 重新运行 `install.sh` 选菜单 **3**,或执行 `python scripts/navctl.py update` 配置:将 `.env.example` 复制为 `.env` 并填写变量(与 `app.py` 同目录);详见说明文档「环境变量与 `.env`」一节。 diff --git a/scripts/deploy.py b/scripts/deploy.py index 08347d4..41e471a 100644 --- a/scripts/deploy.py +++ b/scripts/deploy.py @@ -315,8 +315,8 @@ def main() -> None: args = parse_args() if not (ROOT / "app.py").is_file(): _fail( - "请在 LocalNav 项目目录内运行,或使用 scripts/install.py 自动克隆安装:\n" - " python scripts/install.py" + "请在 LocalNav 项目目录内运行,或使用部署管理脚本:\n" + " python scripts/navctl.py" ) print(f"LocalNav 项目部署 · {platform.system()} {platform.release()}") diff --git a/scripts/install.ps1 b/scripts/install.ps1 index b10617b..24ea52c 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -1,9 +1,9 @@ -# LocalNav 一键安装(无需预先克隆) -# 用法:在 PowerShell 中执行 .\scripts\install.ps1 +# LocalNav 交互式部署管理(无需预先克隆) +# 用法:.\scripts\install.ps1 $ErrorActionPreference = "Stop" $DefaultRepo = "https://git.bz121.com/dekun/LocalNav.git" -$DefaultDir = Join-Path (Get-Location) "LocalNav" +$RawBase = "$($DefaultRepo -replace '\.git$','')/raw/main/scripts" function Need-Cmd($name) { if (-not (Get-Command $name -ErrorAction SilentlyContinue)) { @@ -12,41 +12,20 @@ function Need-Cmd($name) { } Need-Cmd python -Need-Cmd git - -$repo = $env:NAV_INSTALL_REPO -if (-not $repo) { - $inputRepo = Read-Host "Git 仓库地址 [$DefaultRepo]" - $repo = if ($inputRepo) { $inputRepo } else { $DefaultRepo } -} - -$dest = $env:NAV_INSTALL_DIR -if (-not $dest) { - $inputDir = Read-Host "安装目录 [$DefaultDir]" - $dest = if ($inputDir) { $inputDir } else { $DefaultDir } -} $scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path -$installPy = Join-Path $scriptDir "install.py" +$navctlPy = Join-Path $scriptDir "navctl.py" -if (Test-Path $installPy) { - python $installPy --repo $repo --dir $dest +if (-not (Test-Path $navctlPy)) { + $tmpDir = Join-Path $env:TEMP ("localnav-" + [guid]::NewGuid().ToString()) + New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null + Write-Host "[OK] 正在下载部署管理脚本..." + Invoke-WebRequest -Uri "$RawBase/navctl.py" -OutFile (Join-Path $tmpDir "navctl.py") + Invoke-WebRequest -Uri "$RawBase/nav_common.py" -OutFile (Join-Path $tmpDir "nav_common.py") + $env:PYTHONPATH = "$tmpDir;$env:PYTHONPATH" + python (Join-Path $tmpDir "navctl.py") @args + Remove-Item -Recurse -Force $tmpDir exit $LASTEXITCODE } -$destPath = Resolve-Path -LiteralPath $dest -ErrorAction SilentlyContinue -if ($destPath -and (Test-Path (Join-Path $destPath "app.py"))) { - Write-Host "[OK] 目录已是 LocalNav,执行 git pull" - git -C $destPath pull --ff-only -} elseif (Test-Path $dest) { - Write-Error "[FAIL] 目录已存在且不是 LocalNav 仓库: $dest" -} else { - Write-Host "[OK] 正在克隆 $repo -> $dest" - $parent = Split-Path -Parent $dest - if ($parent -and -not (Test-Path $parent)) { - New-Item -ItemType Directory -Path $parent -Force | Out-Null - } - git clone $repo $dest -} - -python (Join-Path $dest "scripts\deploy.py") +python $navctlPy @args diff --git a/scripts/install.py b/scripts/install.py index 68bf5d0..82a73fc 100644 --- a/scripts/install.py +++ b/scripts/install.py @@ -1,163 +1,20 @@ #!/usr/bin/env python3 -"""LocalNav 一键安装:无需预先克隆,输入仓库地址后自动克隆并部署。""" +"""兼容入口:等价于 navctl.py install(保留 --repo / --dir / -y 参数)。""" from __future__ import annotations -import argparse -import os -import platform -import shutil -import subprocess import sys from pathlib import Path -DEFAULT_REPO = "https://git.bz121.com/dekun/LocalNav.git" -MIN_PYTHON = (3, 10) - - -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 default_install_dir() -> Path: - if platform.system() == "Windows": - return Path.cwd() / "LocalNav" - return Path("/opt/LocalNav") - - -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_git() -> str: - git = shutil.which("git") - if not git: - _fail("未检测到 git,请先安装:Ubuntu 执行 apt install -y git") - _ok(f"git 可用 ({git})") - return git - - -def prompt_value(label: str, default: str) -> str: - try: - raw = input(f"{label} [{default}]: ").strip() - except (EOFError, KeyboardInterrupt): - print() - _fail("已取消") - return raw or default - - -def resolve_paths(repo: str, dest: Path) -> tuple[str, Path]: - dest = dest.expanduser().resolve() - repo = repo.strip() - if not repo: - _fail("仓库地址不能为空") - if dest.name == "" or str(dest).endswith(("/", "\\")): - _fail("安装目录无效") - return repo, dest - - -def is_localnav_project(path: Path) -> bool: - return (path / "app.py").is_file() and (path / "scripts" / "deploy.py").is_file() - - -def clone_or_update(git_exe: str, repo: str, dest: Path) -> None: - if dest.exists() and not dest.is_dir(): - _fail(f"安装路径已存在且不是目录: {dest}") - - if is_localnav_project(dest): - _ok(f"目录已是 LocalNav 项目,执行 git pull: {dest}") - subprocess.run([git_exe, "-C", str(dest), "pull", "--ff-only"], check=True) - return - - if dest.exists(): - if (dest / ".git").is_dir(): - _ok(f"目录为 Git 仓库,执行 git pull: {dest}") - subprocess.run([git_exe, "-C", str(dest), "pull", "--ff-only"], check=True) - if not is_localnav_project(dest): - _fail(f"目录 {dest} 不是 LocalNav 项目(缺少 app.py)") - return - _fail(f"目录已存在且不是 Git 仓库: {dest}") - - dest.parent.mkdir(parents=True, exist_ok=True) - _ok(f"正在克隆 {repo} -> {dest}") - subprocess.run([git_exe, "clone", repo, str(dest)], check=True) - if not is_localnav_project(dest): - _fail("克隆完成但未找到 app.py,请检查仓库地址是否正确") - - -def run_project_setup(py_exe: str, project_dir: Path) -> None: - deploy_script = project_dir / "scripts" / "deploy.py" - if not deploy_script.is_file(): - _fail(f"未找到部署脚本: {deploy_script}") - _ok(f"开始项目内安装: {project_dir}") - subprocess.run([py_exe, str(deploy_script)], cwd=project_dir, check=True) - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="LocalNav 一键安装(自动克隆 + 部署)") - parser.add_argument( - "--repo", - default="", - help=f"Git 仓库地址(默认 {DEFAULT_REPO})", - ) - parser.add_argument( - "--dir", - default="", - help="安装目录(Linux 默认 /opt/LocalNav,Windows 默认 ./LocalNav)", - ) - parser.add_argument( - "-y", - "--yes", - action="store_true", - help="使用默认仓库与安装目录,不交互提问", - ) - return parser.parse_args() - - -def main() -> None: - args = parse_args() - print(f"LocalNav 一键安装 · {platform.system()} {platform.release()}") - print("无需预先克隆,将自动拉取代码并完成部署。") - print() - - py_exe = check_python() - git_exe = check_git() - - default_dest = default_install_dir() - if args.yes: - repo = args.repo.strip() or DEFAULT_REPO - dest = Path(args.dir).expanduser() if args.dir.strip() else default_dest - else: - repo = args.repo.strip() or prompt_value("Git 仓库地址", DEFAULT_REPO) - dest_str = args.dir.strip() or prompt_value("安装目录", str(default_dest)) - dest = Path(dest_str) - - repo, dest = resolve_paths(repo, dest) - print() - print(f" 仓库: {repo}") - print(f" 目录: {dest}") - print() - - clone_or_update(git_exe, repo, dest) - run_project_setup(py_exe, dest) - - print() - print(f"安装目录: {dest}") - print(f"进入目录: cd {dest}") +SCRIPT_DIR = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) +from navctl import action_install, main, parse_args # noqa: E402 if __name__ == "__main__": - main() + args = parse_args() + if args.command in (None, "install"): + action_install(repo=args.repo, dest=args.dir, yes=args.yes) + else: + main() diff --git a/scripts/install.sh b/scripts/install.sh index 6992716..a1ba1cc 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash -# LocalNav 一键安装(无需预先克隆) +# LocalNav 交互式部署管理(无需预先克隆) # 用法: # curl -fsSL https://git.bz121.com/dekun/LocalNav/raw/main/scripts/install.sh | bash # bash install.sh set -euo pipefail DEFAULT_REPO="https://git.bz121.com/dekun/LocalNav.git" -DEFAULT_DIR="/opt/LocalNav" +RAW_BASE="${DEFAULT_REPO%.git}/raw/main/scripts" need_cmd() { command -v "$1" >/dev/null 2>&1 || { @@ -16,39 +16,19 @@ need_cmd() { } need_cmd python3 -need_cmd git -REPO="${NAV_INSTALL_REPO:-}" -DEST="${NAV_INSTALL_DIR:-}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd || true)" +NAVCTL_PY="${SCRIPT_DIR}/navctl.py" -if [ -z "$REPO" ]; then - read -rp "Git 仓库地址 [${DEFAULT_REPO}]: " REPO - REPO="${REPO:-$DEFAULT_REPO}" +if [ ! -f "$NAVCTL_PY" ]; then + TMP_DIR="$(mktemp -d)" + trap 'rm -rf "$TMP_DIR"' EXIT + echo "[OK] 正在下载部署管理脚本..." + curl -fsSL "${RAW_BASE}/navctl.py" -o "${TMP_DIR}/navctl.py" + curl -fsSL "${RAW_BASE}/nav_common.py" -o "${TMP_DIR}/nav_common.py" + export PYTHONPATH="${TMP_DIR}:${PYTHONPATH:-}" + exec python3 "${TMP_DIR}/navctl.py" "$@" fi -if [ -z "$DEST" ]; then - read -rp "安装目录 [${DEFAULT_DIR}]: " DEST - DEST="${DEST:-$DEFAULT_DIR}" -fi - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -INSTALL_PY="${SCRIPT_DIR}/install.py" - -if [ -f "$INSTALL_PY" ]; then - exec python3 "$INSTALL_PY" --repo "$REPO" --dir "$DEST" -fi - -# 通过 curl 管道执行时,本机没有 install.py,内联克隆 + 部署 -if [ -d "$DEST/.git" ] && [ -f "$DEST/app.py" ]; then - echo "[OK] 目录已是 LocalNav,执行 git pull" - git -C "$DEST" pull --ff-only -elif [ -d "$DEST" ]; then - echo "[FAIL] 目录已存在且不是 LocalNav 仓库: $DEST" >&2 - exit 1 -else - echo "[OK] 正在克隆 $REPO -> $DEST" - mkdir -p "$(dirname "$DEST")" - git clone "$REPO" "$DEST" -fi - -exec python3 "$DEST/scripts/deploy.py" +cd "$(dirname "$NAVCTL_PY")" +exec python3 "$NAVCTL_PY" "$@" diff --git a/scripts/nav_common.py b/scripts/nav_common.py new file mode 100644 index 0000000..0f36a8c --- /dev/null +++ b/scripts/nav_common.py @@ -0,0 +1,178 @@ +"""LocalNav 部署脚本公共工具。""" + +from __future__ import annotations + +import json +import os +import platform +import shutil +import subprocess +from pathlib import Path + +DEFAULT_REPO = "https://git.bz121.com/dekun/LocalNav.git" +MIN_PYTHON = (3, 10) +APP_NAME = "nav-site" + + +def config_path() -> Path: + if platform.system() == "Windows": + base = Path(os.environ.get("USERPROFILE", Path.home())) / ".localnav" + else: + base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "localnav" + base.mkdir(parents=True, exist_ok=True) + return base / "install.json" + + +def load_install_config() -> dict: + path = config_path() + if not path.is_file(): + return {} + try: + return json.loads(path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return {} + + +def save_install_config(project_dir: Path, repo: str) -> None: + data = { + "dir": str(project_dir.resolve()), + "repo": repo.strip(), + } + config_path().write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + + +def default_install_dir() -> Path: + cfg = load_install_config() + if cfg.get("dir"): + return Path(cfg["dir"]).expanduser() + if os.environ.get("NAV_INSTALL_DIR"): + return Path(os.environ["NAV_INSTALL_DIR"]).expanduser() + if platform.system() == "Windows": + return Path.cwd() / "LocalNav" + return Path("/opt/LocalNav") + + +def default_repo() -> str: + cfg = load_install_config() + return (cfg.get("repo") or os.environ.get("NAV_INSTALL_REPO") or DEFAULT_REPO).strip() + + +def is_localnav_project(path: Path) -> bool: + return (path / "app.py").is_file() and (path / "scripts" / "deploy.py").is_file() + + +def resolve_project_dir(explicit: str | None = None) -> Path | None: + if explicit and explicit.strip(): + p = Path(explicit.strip()).expanduser().resolve() + return p if is_localnav_project(p) else None + cfg_dir = load_install_config().get("dir", "").strip() + if cfg_dir and is_localnav_project(Path(cfg_dir)): + return Path(cfg_dir).resolve() + here = Path.cwd() + if is_localnav_project(here): + return here.resolve() + return None + + +def pm2_available() -> str | None: + return shutil.which("pm2") + + +def pm2_has_app(name: str = APP_NAME) -> bool: + pm2 = pm2_available() + if not pm2: + return False + try: + proc = subprocess.run( + [pm2, "jlist"], + check=True, + capture_output=True, + text=True, + ) + apps = json.loads(proc.stdout or "[]") + return any(app.get("name") == name for app in apps) + except (subprocess.CalledProcessError, json.JSONDecodeError, OSError): + return False + + +def pm2_app_status(name: str = APP_NAME) -> str | None: + pm2 = pm2_available() + if not pm2: + return None + try: + proc = subprocess.run( + [pm2, "jlist"], + check=True, + capture_output=True, + text=True, + ) + for app in json.loads(proc.stdout or "[]"): + if app.get("name") == name: + return str(app.get("pm2_env", {}).get("status") or "unknown") + except (subprocess.CalledProcessError, json.JSONDecodeError, OSError): + return None + return None + + +def systemd_unit_ready(name: str = APP_NAME) -> bool: + systemctl = shutil.which("systemctl") + if not systemctl: + return False + try: + proc = subprocess.run( + [systemctl, "list-unit-files", f"{name}.service", "--no-pager", "--no-legend"], + check=True, + capture_output=True, + text=True, + ) + return bool((proc.stdout or "").strip()) + except (subprocess.CalledProcessError, OSError): + return False + + +def systemd_active(name: str = APP_NAME) -> bool: + systemctl = shutil.which("systemctl") + if not systemctl: + return False + try: + proc = subprocess.run( + [systemctl, "is-active", f"{name}.service"], + check=True, + capture_output=True, + text=True, + ) + return (proc.stdout or "").strip() == "active" + except (subprocess.CalledProcessError, OSError): + return False + + +def service_status_text(project_dir: Path | None) -> str: + if not project_dir: + return "未安装" + pm2_state = pm2_app_status() + if pm2_state: + return f"pm2 {pm2_state}" + if systemd_unit_ready() and systemd_active(): + return "systemd active" + if systemd_unit_ready(): + return "systemd 已配置但未运行" + return "未运行" + + +def stop_service() -> list[str]: + """停止并移除进程管理中的 nav-site,返回已执行操作说明。""" + actions: list[str] = [] + pm2 = pm2_available() + if pm2 and pm2_has_app(): + subprocess.run([pm2, "stop", APP_NAME], check=False) + subprocess.run([pm2, "delete", APP_NAME], check=False) + subprocess.run([pm2, "save"], check=False) + actions.append("pm2 stop/delete nav-site") + + systemctl = shutil.which("systemctl") + if systemctl and systemd_unit_ready(): + subprocess.run([systemctl, "stop", f"{APP_NAME}.service"], check=False) + subprocess.run([systemctl, "disable", f"{APP_NAME}.service"], check=False) + actions.append("systemctl stop/disable nav-site") + + return actions diff --git a/scripts/navctl.ps1 b/scripts/navctl.ps1 new file mode 100644 index 0000000..e115da6 --- /dev/null +++ b/scripts/navctl.ps1 @@ -0,0 +1,3 @@ +$ErrorActionPreference = "Stop" +Set-Location (Split-Path -Parent $MyInvocation.MyCommand.Path) +python navctl.py @args diff --git a/scripts/navctl.py b/scripts/navctl.py new file mode 100644 index 0000000..86db196 --- /dev/null +++ b/scripts/navctl.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +"""LocalNav 交互式部署管理:安装 / 卸载 / 更新。""" + +from __future__ import annotations + +import argparse +import platform +import shutil +import subprocess +import sys +from pathlib import Path + +from nav_common import ( + APP_NAME, + MIN_PYTHON, + default_install_dir, + default_repo, + is_localnav_project, + load_install_config, + pm2_app_status, + resolve_project_dir, + save_install_config, + service_status_text, + stop_service, +) + +SCRIPT_DIR = Path(__file__).resolve().parent + + +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 _pause() -> None: + try: + input("\n按回车返回菜单...") + except (EOFError, KeyboardInterrupt): + print() + + +def prompt_value(label: str, default: str) -> str: + try: + raw = input(f"{label} [{default}]: ").strip() + except (EOFError, KeyboardInterrupt): + print() + _fail("已取消") + return raw or default + + +def confirm(prompt: str, *, default_no: bool = True) -> bool: + suffix = " [y/N]: " if default_no else " [Y/n]: " + try: + raw = input(prompt + suffix).strip().lower() + except (EOFError, KeyboardInterrupt): + print() + return False + if not raw: + return not default_no + return raw in ("y", "yes", "是") + + +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}") + return sys.executable + + +def check_git() -> str: + git = shutil.which("git") + if not git: + _fail("未检测到 git,请先安装:sudo apt install -y git") + return git + + +def clone_project(git_exe: str, repo: str, dest: Path) -> None: + if dest.exists() and not dest.is_dir(): + _fail(f"安装路径已存在且不是目录: {dest}") + + if is_localnav_project(dest): + _ok(f"目录已是 LocalNav 项目: {dest}") + return + + if dest.exists(): + if (dest / ".git").is_dir(): + _fail(f"目录已存在且不是 LocalNav 项目: {dest}") + _fail(f"目录已存在: {dest}") + + dest.parent.mkdir(parents=True, exist_ok=True) + _ok(f"正在克隆 {repo} -> {dest}") + subprocess.run([git_exe, "clone", repo, str(dest)], check=True) + if not is_localnav_project(dest): + _fail("克隆完成但未找到 app.py,请检查仓库地址") + + +def git_pull(project_dir: Path) -> None: + git = check_git() + if not (project_dir / ".git").is_dir(): + _fail(f"目录不是 Git 仓库,无法更新: {project_dir}") + _ok(f"正在拉取最新代码: {project_dir}") + subprocess.run([git, "-C", str(project_dir), "pull", "--ff-only"], check=True) + + +def run_deploy(project_dir: Path, py_exe: str) -> None: + deploy_script = project_dir / "scripts" / "deploy.py" + if not deploy_script.is_file(): + _fail(f"未找到部署脚本: {deploy_script}") + subprocess.run([py_exe, str(deploy_script)], cwd=project_dir, check=True) + + +def action_install(*, repo: str = "", dest: str = "", yes: bool = False) -> None: + print() + print("=" * 50) + print(" 一键部署安装") + print("=" * 50) + + py_exe = check_python() + git_exe = check_git() + + repo = repo.strip() or default_repo() + if yes: + project_dir = Path(dest).expanduser() if dest.strip() else default_install_dir() + else: + repo = prompt_value("Git 仓库地址", repo) + dest_str = dest.strip() or prompt_value("安装目录", str(default_install_dir())) + project_dir = Path(dest_str).expanduser() + + project_dir = project_dir.resolve() + print() + print(f" 仓库: {repo}") + print(f" 目录: {project_dir}") + print() + + clone_project(git_exe, repo, project_dir) + run_deploy(project_dir, py_exe) + save_install_config(project_dir, repo) + _ok(f"安装完成: {project_dir}") + + +def action_update(*, dest: str = "") -> None: + print() + print("=" * 50) + print(" 更新") + print("=" * 50) + + project_dir = resolve_project_dir(dest) + if not project_dir: + dest_str = dest.strip() or prompt_value("安装目录", str(default_install_dir())) + project_dir = Path(dest_str).expanduser().resolve() + if not is_localnav_project(project_dir): + _fail(f"未找到 LocalNav 安装: {project_dir}") + + py_exe = check_python() + git_pull(project_dir) + run_deploy(project_dir, py_exe) + cfg = load_install_config() + save_install_config(project_dir, cfg.get("repo") or default_repo()) + _ok(f"更新完成: {project_dir}") + + +def action_uninstall(*, dest: str = "") -> None: + print() + print("=" * 50) + print(" 一键卸载") + print("=" * 50) + + project_dir = resolve_project_dir(dest) + if not project_dir: + dest_str = dest.strip() or prompt_value("安装目录", str(default_install_dir())) + candidate = Path(dest_str).expanduser().resolve() + if candidate.exists() and is_localnav_project(candidate): + project_dir = candidate + elif not candidate.exists(): + _warn(f"目录不存在: {candidate}") + project_dir = None + else: + _fail(f"目录不是 LocalNav 项目: {candidate}") + + print() + if project_dir: + print(f" 安装目录: {project_dir}") + print(f" 服务状态: {service_status_text(project_dir)}") + else: + print(" 未检测到已安装的 LocalNav 项目") + print() + + if not confirm("确认停止 nav-site 服务?", default_no=True): + _warn("已取消卸载") + return + + actions = stop_service() + if actions: + for item in actions: + _ok(item) + else: + _warn("未发现 pm2 / systemd 中的 nav-site 进程") + + if not project_dir: + _ok("卸载完成(仅停止服务)") + return + + if confirm("是否删除整个安装目录(含数据库,不可恢复)?", default_no=True): + if not confirm("再次确认:删除 " + str(project_dir) + " ?", default_no=True): + _warn("已保留安装目录") + else: + shutil.rmtree(project_dir) + _ok(f"已删除目录: {project_dir}") + cfg = load_install_config() + if cfg.get("dir") == str(project_dir): + from nav_common import config_path + + try: + config_path().unlink(missing_ok=True) + except OSError: + pass + else: + _warn(f"已保留安装目录: {project_dir}") + + _ok("卸载完成") + + +def render_menu(project_dir: Path | None) -> None: + print() + print("=" * 50) + print(" LocalNav 部署管理") + print("=" * 50) + if project_dir: + print(f" 安装目录: {project_dir}") + else: + print(f" 安装目录: 未安装(默认 {default_install_dir()})") + print(f" 服务状态: {service_status_text(project_dir)}") + if pm2_app_status(): + print(f" PM2 进程: {APP_NAME}") + print("-" * 50) + print(" 1 一键部署安装") + print(" 2 一键卸载") + print(" 3 更新") + print(" 0 退出") + print("-" * 50) + + +def interactive_menu() -> None: + print(f"LocalNav 部署管理 · {platform.system()} {platform.release()}") + while True: + project_dir = resolve_project_dir() + render_menu(project_dir) + try: + choice = input("请选择: ").strip() + except (EOFError, KeyboardInterrupt): + print("\n再见。") + return + + if choice == "1": + try: + action_install() + except subprocess.CalledProcessError as exc: + _fail(f"安装失败: {exc}") + _pause() + elif choice == "2": + try: + action_uninstall() + except subprocess.CalledProcessError as exc: + _fail(f"卸载失败: {exc}") + _pause() + elif choice == "3": + try: + action_update() + except subprocess.CalledProcessError as exc: + _fail(f"更新失败: {exc}") + _pause() + elif choice == "0": + print("再见。") + return + else: + _warn("无效选项,请输入 0-3") + _pause() + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="LocalNav 部署管理") + parser.add_argument( + "command", + nargs="?", + choices=("install", "uninstall", "update", "menu"), + help="直接执行指定操作;省略则进入交互菜单", + ) + parser.add_argument("--repo", default="", help="Git 仓库地址") + parser.add_argument("--dir", default="", help="安装目录") + parser.add_argument("-y", "--yes", action="store_true", help="安装时使用默认路径,减少提问") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + if args.command == "install": + action_install(repo=args.repo, dest=args.dir, yes=args.yes) + elif args.command == "uninstall": + action_uninstall(dest=args.dir) + elif args.command == "update": + action_update(dest=args.dir) + elif args.command == "menu": + interactive_menu() + else: + interactive_menu() + + +if __name__ == "__main__": + main() diff --git a/scripts/navctl.sh b/scripts/navctl.sh new file mode 100644 index 0000000..9362469 --- /dev/null +++ b/scripts/navctl.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")" +exec python3 navctl.py "$@" diff --git a/部署与使用说明.md b/部署与使用说明.md index 77bff6d..46c9bff 100644 --- a/部署与使用说明.md +++ b/部署与使用说明.md @@ -54,10 +54,12 @@ ├── .env # 本地配置(自建,勿提交 Git) ├── ecosystem.config.cjs # PM2 守护进程配置 ├── scripts/ -│ ├── install.py # 一键安装(输入仓库地址,自动克隆 + 部署) -│ ├── install.sh # Linux/macOS 安装封装(支持 curl | bash) -│ ├── install.ps1 # Windows 安装封装 -│ ├── deploy.py # 项目内更新部署(需已在仓库目录) +│ ├── navctl.py # 交互式部署管理(安装 / 卸载 / 更新) +│ ├── nav_common.py # 部署脚本公共工具 +│ ├── install.sh # Linux 入口(支持 curl | bash) +│ ├── install.ps1 # Windows 入口 +│ ├── install.py # 兼容:等价 navctl install +│ ├── deploy.py # 项目内安装逻辑(由 navctl 调用) │ ├── deploy.sh # 项目内更新封装 │ ├── deploy.ps1 # 项目内更新封装 │ └── cleanup_gate_scout.py # 可选:清理旧「Gate 扫单」分组 @@ -79,46 +81,42 @@ ## 五、一键部署(推荐,无需预先克隆) -在**新机器**上无需先 `git clone`,运行安装脚本后按提示输入仓库地址即可自动克隆并完成部署。 +运行安装脚本后进入**交互式部署菜单**,无需预先 `git clone`。 -### 5.1 首次安装 +### 5.1 启动菜单 ```bash -# Linux / macOS(一条命令,从远程拉取安装脚本) +# Linux / macOS(一条命令) curl -fsSL https://git.bz121.com/dekun/LocalNav/raw/main/scripts/install.sh | bash -``` -也可将 `install.sh` / `install.py` 拷贝到本机后执行: - -```bash -bash scripts/install.sh # 或 -python3 scripts/install.py +bash scripts/install.sh +python3 scripts/navctl.py ``` -**Windows PowerShell:** +**Windows:** `.\scripts\install.ps1` -```powershell -.\scripts\install.ps1 -``` +**菜单:** -**交互提示:** +| 按键 | 功能 | +|------|------| +| **1** | 一键部署安装:输入仓库地址 → 克隆 → 安装依赖 → 生成 `.env` → 自动 pm2 启动 | +| **2** | 一键卸载:停止 pm2/systemd;可选删除安装目录(含数据库,需二次确认) | +| **3** | 更新:`git pull` + 重新部署 + 自动重启 | +| **0** | 退出 | -| 提示项 | 默认值 | -|--------|--------| -| Git 仓库地址 | `https://git.bz121.com/dekun/LocalNav.git` | -| 安装目录 | Linux:`/opt/LocalNav`;Windows:`./LocalNav` | +安装信息会保存到 `~/.config/localnav/install.json`,下次打开菜单自动识别安装目录与服务状态。 -**非交互(使用默认值):** +### 5.2 非交互命令 ```bash -python3 scripts/install.py -y -python3 scripts/install.py --repo https://git.bz121.com/dekun/LocalNav.git --dir /opt/LocalNav +python3 scripts/navctl.py install -y +python3 scripts/navctl.py install --repo https://git.bz121.com/dekun/LocalNav.git --dir /opt/LocalNav +python3 scripts/navctl.py update --dir /opt/LocalNav +python3 scripts/navctl.py uninstall --dir /opt/LocalNav ``` -环境变量可跳过提问:`NAV_INSTALL_REPO`、`NAV_INSTALL_DIR`。 - -### 5.2 自动完成的步骤 +### 5.3 自动完成的步骤(安装 / 更新) | 步骤 | 说明 | |------|------| @@ -132,14 +130,15 @@ python3 scripts/install.py --repo https://git.bz121.com/dekun/LocalNav.git --dir | 内网 Cookie | 默认 `NAV_COOKIES_INSECURE_HTTP=1`(便于 `http://IP:端口` 访问) | | **自动启动** | 若检测到 **pm2**:`pm2 start` 或 `pm2 restart nav-site`;否则尝试 **systemd** `nav-site` 服务 | -**注意:** 脚本不会覆盖 `.env` 中已有的 `NAV_SECRET_KEY` 等配置。生产环境部署后请尽快登录并在「系统设置」中修改密码。仅安装不启动可加 `--no-start`。 +**注意:** 脚本不会覆盖 `.env` 中已有的 `NAV_SECRET_KEY` 等配置。生产环境部署后请尽快登录并在「系统设置」中修改密码。仅安装不启动可在 `deploy.py` 加 `--no-start`。 -### 5.3 已安装后的更新 +### 5.4 已安装后的更新 + +也可在菜单选 **3**,或: ```bash cd /opt/LocalNav -git pull -python3 scripts/deploy.py # 或 bash scripts/deploy.sh +python3 scripts/navctl.py update ``` 部署完成后启动: