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:
@@ -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
|
||||
Reference in New Issue
Block a user