Add interactive navctl menu for install, uninstall, and update.

Provide navctl.py with a numbered menu, persist install path in config, and support curl bootstrap without pre-cloning the repo.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-12 12:01:01 +08:00
parent fc23a95e5c
commit f508f210f9
11 changed files with 608 additions and 278 deletions
+178
View File
@@ -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