install script
This commit is contained in:
Executable
+775
@@ -0,0 +1,775 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
install_neovim.py
|
||||
|
||||
Install Neovim from the latest official GitHub binary release, then install
|
||||
LazyVim and patch the Telescope config to show hidden files.
|
||||
|
||||
Targets:
|
||||
Windows 10/11 (x86_64), macOS (arm64 / x86_64), Linux (x86_64 / arm64).
|
||||
|
||||
Requirements:
|
||||
Python 3.8+, git on PATH, network access to api.github.com and github.com.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
LOG = logging.getLogger("install-nvim")
|
||||
|
||||
NVIM_LATEST_API = "https://api.github.com/repos/neovim/neovim/releases/latest"
|
||||
LAZYVIM_STARTER_REPO = "https://github.com/LazyVim/starter"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Target detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Target:
|
||||
os_name: str # "windows" | "macos" | "linux"
|
||||
arch: str # "x86_64" | "arm64"
|
||||
asset_patterns: Tuple[str, ...] # substrings to look for, in priority order
|
||||
archive_kind: str # "zip" | "tar.gz"
|
||||
|
||||
|
||||
TARGETS = {
|
||||
("windows", "x86_64"): Target(
|
||||
"windows", "x86_64",
|
||||
("nvim-win64.zip", "win64.zip"),
|
||||
"zip",
|
||||
),
|
||||
("macos", "arm64"): Target(
|
||||
"macos", "arm64",
|
||||
("nvim-macos-arm64.tar.gz", "macos-arm64.tar.gz"),
|
||||
"tar.gz",
|
||||
),
|
||||
("macos", "x86_64"): Target(
|
||||
"macos", "x86_64",
|
||||
("nvim-macos-x86_64.tar.gz", "macos-x86_64.tar.gz"),
|
||||
"tar.gz",
|
||||
),
|
||||
("linux", "x86_64"): Target(
|
||||
"linux", "x86_64",
|
||||
# Asset naming has shifted across recent releases; try newer names first.
|
||||
("nvim-linux-x86_64.tar.gz", "nvim-linux64.tar.gz",
|
||||
"linux-x86_64.tar.gz", "linux64.tar.gz"),
|
||||
"tar.gz",
|
||||
),
|
||||
("linux", "arm64"): Target(
|
||||
"linux", "arm64",
|
||||
("nvim-linux-arm64.tar.gz", "linux-arm64.tar.gz"),
|
||||
"tar.gz",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def detect_target() -> Optional[Target]:
|
||||
sys_name = platform.system().lower()
|
||||
machine = platform.machine().lower()
|
||||
|
||||
if sys_name.startswith("win"):
|
||||
return TARGETS.get(("windows", "x86_64"))
|
||||
if sys_name == "darwin":
|
||||
if machine in ("arm64", "aarch64"):
|
||||
return TARGETS[("macos", "arm64")]
|
||||
if machine in ("x86_64", "amd64"):
|
||||
return TARGETS[("macos", "x86_64")]
|
||||
if sys_name == "linux":
|
||||
if machine in ("aarch64", "arm64"):
|
||||
return TARGETS[("linux", "arm64")]
|
||||
if machine in ("x86_64", "amd64"):
|
||||
return TARGETS[("linux", "x86_64")]
|
||||
return None
|
||||
|
||||
|
||||
def prompt_target() -> Target:
|
||||
print()
|
||||
print("Could not auto-detect a supported OS/architecture.")
|
||||
print("Please choose a target:")
|
||||
options = [
|
||||
("Windows (x86_64)", ("windows", "x86_64")),
|
||||
("macOS (Apple Silicon)", ("macos", "arm64")),
|
||||
("macOS (Intel)", ("macos", "x86_64")),
|
||||
("Linux (x86_64)", ("linux", "x86_64")),
|
||||
("Linux (ARM64)", ("linux", "arm64")),
|
||||
]
|
||||
for i, (label, _) in enumerate(options, 1):
|
||||
print(f" {i}) {label}")
|
||||
while True:
|
||||
choice = input(f"Select [1-{len(options)}] or q to quit: ").strip().lower()
|
||||
if choice in ("q", "quit", "exit"):
|
||||
sys.exit(1)
|
||||
if choice.isdigit() and 1 <= int(choice) <= len(options):
|
||||
return TARGETS[options[int(choice) - 1][1]]
|
||||
print("Invalid selection.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class InstallError(RuntimeError):
|
||||
"""Raised on user-facing install failures."""
|
||||
|
||||
|
||||
def die(msg: str, code: int = 1) -> None:
|
||||
LOG.error(msg)
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def require_git() -> None:
|
||||
if shutil.which("git") is None:
|
||||
die("git is required but was not found on PATH. "
|
||||
"Install git first, then re-run this script.")
|
||||
|
||||
|
||||
def http_get_json(url: str) -> dict:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "install-neovim.py"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return json.loads(r.read().decode("utf-8"))
|
||||
except urllib.error.URLError as e:
|
||||
raise InstallError(f"Failed to reach {url}: {e.reason}") from e
|
||||
|
||||
|
||||
def http_download(url: str, dest: Path) -> None:
|
||||
LOG.info("Downloading %s", url)
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "install-neovim.py"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as r, open(dest, "wb") as f:
|
||||
shutil.copyfileobj(r, f)
|
||||
except urllib.error.URLError as e:
|
||||
raise InstallError(f"Download failed: {e.reason}") from e
|
||||
|
||||
|
||||
def find_release_asset(release: dict, patterns: Tuple[str, ...]) -> Tuple[str, str]:
|
||||
assets = release.get("assets", [])
|
||||
for pat in patterns:
|
||||
for asset in assets:
|
||||
if pat in asset["name"]:
|
||||
return asset["name"], asset["browser_download_url"]
|
||||
available = ", ".join(a["name"] for a in assets) or "<none>"
|
||||
raise InstallError(
|
||||
f"No matching release asset for patterns {patterns}.\n"
|
||||
f"Available assets: {available}"
|
||||
)
|
||||
|
||||
|
||||
def _single_top_level(names) -> str:
|
||||
tops = {n.split("/", 1)[0].rstrip("\\") for n in names if n and not n.startswith("/")}
|
||||
tops.discard("")
|
||||
if len(tops) != 1:
|
||||
raise InstallError(f"Archive does not have a single top-level directory: {tops}")
|
||||
return next(iter(tops))
|
||||
|
||||
|
||||
def extract_archive(archive: Path, into: Path, kind: str) -> Path:
|
||||
into.mkdir(parents=True, exist_ok=True)
|
||||
if kind == "zip":
|
||||
with zipfile.ZipFile(archive) as zf:
|
||||
top = _single_top_level(zf.namelist())
|
||||
zf.extractall(into)
|
||||
else:
|
||||
with tarfile.open(archive, "r:gz") as tf:
|
||||
members = tf.getmembers()
|
||||
if not members:
|
||||
raise InstallError("Archive is empty.")
|
||||
top = _single_top_level(m.name for m in members)
|
||||
try:
|
||||
tf.extractall(into, filter="data") # Python 3.12+
|
||||
except TypeError:
|
||||
tf.extractall(into)
|
||||
extracted = into / top
|
||||
if not extracted.is_dir():
|
||||
raise InstallError(
|
||||
f"Extraction did not produce expected top-level directory: {extracted}"
|
||||
)
|
||||
return extracted
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Privilege detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def is_admin_windows() -> bool:
|
||||
try:
|
||||
return bool(ctypes.windll.shell32.IsUserAnAdmin())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def is_root_unix() -> bool:
|
||||
return hasattr(os, "geteuid") and os.geteuid() == 0
|
||||
|
||||
|
||||
def relaunch_as_admin_windows() -> bool:
|
||||
"""
|
||||
Re-launch the current script under UAC elevation.
|
||||
|
||||
Returns True if a new elevated process was successfully started (the
|
||||
caller should then exit). Returns False if elevation was declined by
|
||||
the user or unavailable, in which case the caller should continue
|
||||
running unprivileged.
|
||||
"""
|
||||
# Pass a sentinel flag so the elevated process does not try to elevate again.
|
||||
if getattr(sys, "frozen", False):
|
||||
executable = sys.executable
|
||||
params_list = list(sys.argv[1:]) + ["--elevated-relaunch"]
|
||||
else:
|
||||
executable = sys.executable
|
||||
params_list = [sys.argv[0]] + list(sys.argv[1:]) + ["--elevated-relaunch"]
|
||||
|
||||
params = subprocess.list2cmdline(params_list)
|
||||
|
||||
SW_SHOWNORMAL = 1
|
||||
try:
|
||||
ret = ctypes.windll.shell32.ShellExecuteW(
|
||||
None, "runas", executable, params, None, SW_SHOWNORMAL,
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
LOG.warning("Failed to invoke UAC elevation: %s", e)
|
||||
return False
|
||||
|
||||
# ShellExecuteW returns an HINSTANCE cast to int.
|
||||
# Values > 32 indicate success; <= 32 indicate failure.
|
||||
# SE_ERR_ACCESSDENIED (5) is what you get when the user clicks "No" on UAC.
|
||||
if ret <= 32:
|
||||
LOG.warning("UAC elevation declined or unavailable (code %s).", ret)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Install paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def system_install_dir(target: Target) -> Path:
|
||||
if target.os_name == "windows":
|
||||
program_files = os.environ.get("ProgramFiles", r"C:\Program Files")
|
||||
return Path(program_files) / "Neovim"
|
||||
if target.os_name == "macos":
|
||||
return Path("/Applications/Neovim")
|
||||
return Path("/opt/nvim") # /usr/bin holds individual binaries; /opt is the conventional home for self-contained trees.
|
||||
|
||||
|
||||
def user_install_dir(target: Target) -> Path:
|
||||
home = Path.home()
|
||||
if target.os_name == "windows":
|
||||
return home / ".bin" / "Neovim"
|
||||
return home / ".bin" / "nvim"
|
||||
|
||||
|
||||
def bin_dir_for(install_dir: Path) -> Path:
|
||||
return install_dir / "bin"
|
||||
|
||||
|
||||
def installed_nvim_version(install_dir: Path, target: Target) -> Optional[str]:
|
||||
"""
|
||||
Return the version token reported by `nvim --version` (e.g. 'v0.10.2')
|
||||
if a working binary exists under `install_dir/bin`, else None.
|
||||
"""
|
||||
nvim_exe = "nvim.exe" if target.os_name == "windows" else "nvim"
|
||||
binary = install_dir / "bin" / nvim_exe
|
||||
if not binary.exists():
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(binary), "--version"],
|
||||
check=True, capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
|
||||
return None
|
||||
first_line = result.stdout.splitlines()[0] if result.stdout else ""
|
||||
parts = first_line.strip().split()
|
||||
if len(parts) >= 2 and parts[0].upper() == "NVIM":
|
||||
return parts[1]
|
||||
return None
|
||||
|
||||
|
||||
def find_existing_install(target: Target,
|
||||
expected_tag: str) -> Optional[Tuple[Path, bool]]:
|
||||
"""
|
||||
If Neovim with version `expected_tag` is already installed at the system
|
||||
or user location, return (install_dir, system_wide). Else None.
|
||||
|
||||
Prefers the system location if both happen to satisfy the version match.
|
||||
"""
|
||||
if not expected_tag:
|
||||
return None
|
||||
sys_dir = system_install_dir(target)
|
||||
user_dir = user_install_dir(target)
|
||||
|
||||
sys_ver = installed_nvim_version(sys_dir, target)
|
||||
if sys_ver and sys_ver == expected_tag:
|
||||
return sys_dir, True
|
||||
user_ver = installed_nvim_version(user_dir, target)
|
||||
if user_ver and user_ver == expected_tag:
|
||||
return user_dir, False
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Move / install with optional privilege escalation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _replace_dir_no_sudo(src: Path, dest: Path) -> None:
|
||||
if dest.exists():
|
||||
shutil.rmtree(dest)
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(src), str(dest))
|
||||
|
||||
|
||||
def _replace_dir_sudo(src: Path, dest: Path) -> None:
|
||||
if dest.exists():
|
||||
subprocess.run(["sudo", "rm", "-rf", str(dest)], check=True)
|
||||
subprocess.run(["sudo", "mkdir", "-p", str(dest.parent)], check=True)
|
||||
subprocess.run(["sudo", "mv", str(src), str(dest)], check=True)
|
||||
|
||||
|
||||
def install_to_dir(src: Path, dest: Path, *, allow_sudo: bool) -> bool:
|
||||
try:
|
||||
_replace_dir_no_sudo(src, dest)
|
||||
return True
|
||||
except (PermissionError, OSError) as e:
|
||||
if not allow_sudo:
|
||||
LOG.debug("Direct install to %s failed: %s", dest, e)
|
||||
return False
|
||||
try:
|
||||
_replace_dir_sudo(src, dest)
|
||||
return True
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
||||
LOG.debug("sudo install to %s failed: %s", dest, e)
|
||||
return False
|
||||
|
||||
|
||||
def place_neovim(extracted: Path, target: Target) -> Tuple[Path, bool]:
|
||||
"""
|
||||
Place the extracted Neovim folder. Returns (final_install_dir, system_wide).
|
||||
Tries the system location first; falls back to a user-local directory.
|
||||
"""
|
||||
sys_dir = system_install_dir(target)
|
||||
user_dir = user_install_dir(target)
|
||||
|
||||
if target.os_name == "windows":
|
||||
if is_admin_windows():
|
||||
LOG.info("Running as admin. Installing to %s", sys_dir)
|
||||
if install_to_dir(extracted, sys_dir, allow_sudo=False):
|
||||
return sys_dir, True
|
||||
LOG.warning("Admin install to %s failed; falling back to user install.", sys_dir)
|
||||
else:
|
||||
LOG.info("Not running as admin; installing for the current user only.")
|
||||
else:
|
||||
LOG.info("Attempting system-wide install to %s "
|
||||
"(you may be prompted for your password).", sys_dir)
|
||||
allow_sudo = not is_root_unix()
|
||||
if install_to_dir(extracted, sys_dir, allow_sudo=allow_sudo):
|
||||
return sys_dir, True
|
||||
LOG.warning("System-wide install failed; falling back to user install.")
|
||||
|
||||
if install_to_dir(extracted, user_dir, allow_sudo=False):
|
||||
return user_dir, False
|
||||
raise InstallError(
|
||||
f"Could not install Neovim to {sys_dir} or {user_dir}. "
|
||||
f"Check filesystem permissions and disk space."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PATH updates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def update_path_windows(bin_dir: Path, system_wide: bool) -> None:
|
||||
import winreg # Windows only
|
||||
|
||||
if system_wide:
|
||||
root = winreg.HKEY_LOCAL_MACHINE
|
||||
sub = r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment"
|
||||
scope = "system"
|
||||
else:
|
||||
root = winreg.HKEY_CURRENT_USER
|
||||
sub = "Environment"
|
||||
scope = "user"
|
||||
|
||||
try:
|
||||
with winreg.OpenKey(root, sub, 0, winreg.KEY_READ | winreg.KEY_WRITE) as key:
|
||||
try:
|
||||
current, kind = winreg.QueryValueEx(key, "Path")
|
||||
except FileNotFoundError:
|
||||
current, kind = "", winreg.REG_EXPAND_SZ
|
||||
entries = [p for p in current.split(";") if p]
|
||||
target_str = str(bin_dir).rstrip("\\")
|
||||
if any(e.rstrip("\\").lower() == target_str.lower() for e in entries):
|
||||
LOG.info("%s PATH already contains %s", scope, bin_dir)
|
||||
return
|
||||
entries.append(str(bin_dir))
|
||||
new_value = ";".join(entries)
|
||||
winreg.SetValueEx(key, "Path", 0, kind or winreg.REG_EXPAND_SZ, new_value)
|
||||
except PermissionError as e:
|
||||
raise InstallError(
|
||||
f"Permission denied updating the {scope} PATH. "
|
||||
f"Re-run this script as administrator, or do a user-only install."
|
||||
) from e
|
||||
|
||||
# Notify other processes (Explorer, new shells) that the environment changed.
|
||||
HWND_BROADCAST = 0xFFFF
|
||||
WM_SETTINGCHANGE = 0x001A
|
||||
SMTO_ABORTIFHUNG = 0x0002
|
||||
result = ctypes.c_long()
|
||||
ctypes.windll.user32.SendMessageTimeoutW(
|
||||
HWND_BROADCAST, WM_SETTINGCHANGE, 0, "Environment",
|
||||
SMTO_ABORTIFHUNG, 5000, ctypes.byref(result),
|
||||
)
|
||||
LOG.info("Added %s to %s PATH.", bin_dir, scope)
|
||||
|
||||
|
||||
def update_path_unix(bin_dir: Path) -> None:
|
||||
zshrc = Path.home() / ".zshrc"
|
||||
bin_str = str(bin_dir)
|
||||
if zshrc.exists() and bin_str in zshrc.read_text(encoding="utf-8", errors="replace"):
|
||||
LOG.info("%s already references %s", zshrc, bin_dir)
|
||||
return
|
||||
|
||||
block = (
|
||||
"\n# >>> neovim install script >>>\n"
|
||||
f'export PATH="{bin_str}:$PATH"\n'
|
||||
"# <<< neovim install script <<<\n"
|
||||
)
|
||||
try:
|
||||
with zshrc.open("a", encoding="utf-8") as f:
|
||||
f.write(block)
|
||||
except OSError as e:
|
||||
raise InstallError(f"Failed to write to {zshrc}: {e}") from e
|
||||
LOG.info("Appended PATH entry to %s", zshrc)
|
||||
|
||||
if os.environ.get("SHELL", "").endswith("bash"):
|
||||
LOG.warning(
|
||||
"Your current shell appears to be bash. The script wrote to ~/.zshrc "
|
||||
"as requested; if you primarily use bash, append the same export line to ~/.bashrc."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verify install
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def verify_neovim(bin_dir: Path, target: Target) -> Path:
|
||||
nvim_exe = "nvim.exe" if target.os_name == "windows" else "nvim"
|
||||
binary = bin_dir / nvim_exe
|
||||
if not binary.exists():
|
||||
raise InstallError(f"Neovim binary missing at {binary}.")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(binary), "--version"],
|
||||
check=True, capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as e:
|
||||
raise InstallError(f"Neovim binary at {binary} failed to run: {e}") from e
|
||||
first_line = result.stdout.splitlines()[0] if result.stdout else "(no output)"
|
||||
LOG.info("Neovim is installed: %s", first_line)
|
||||
return binary
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LazyVim install
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def lazyvim_paths(target: Target) -> dict:
|
||||
home = Path.home()
|
||||
if target.os_name == "windows":
|
||||
local = Path(os.environ.get("LOCALAPPDATA", str(home / "AppData" / "Local")))
|
||||
return {
|
||||
"config": local / "nvim",
|
||||
"data": local / "nvim-data",
|
||||
}
|
||||
xdg_config = Path(os.environ.get("XDG_CONFIG_HOME", str(home / ".config")))
|
||||
xdg_data = Path(os.environ.get("XDG_DATA_HOME", str(home / ".local" / "share")))
|
||||
xdg_state = Path(os.environ.get("XDG_STATE_HOME", str(home / ".local" / "state")))
|
||||
xdg_cache = Path(os.environ.get("XDG_CACHE_HOME", str(home / ".cache")))
|
||||
return {
|
||||
"config": xdg_config / "nvim",
|
||||
"data": xdg_data / "nvim",
|
||||
"state": xdg_state / "nvim",
|
||||
"cache": xdg_cache / "nvim",
|
||||
}
|
||||
|
||||
|
||||
def backup_path(p: Path) -> None:
|
||||
if not p.exists():
|
||||
return
|
||||
candidate = p.with_name(p.name + ".bak")
|
||||
i = 1
|
||||
while candidate.exists():
|
||||
candidate = p.with_name(f"{p.name}.bak.{i}")
|
||||
i += 1
|
||||
LOG.info("Backing up existing %s -> %s", p, candidate)
|
||||
p.rename(candidate)
|
||||
|
||||
|
||||
def lazyvim_already_installed(config_dir: Path) -> bool:
|
||||
"""
|
||||
Heuristic: the LazyVim starter places its bootstrapping in
|
||||
lua/config/lazy.lua. If that file exists, treat LazyVim as installed.
|
||||
"""
|
||||
return (config_dir / "lua" / "config" / "lazy.lua").is_file()
|
||||
|
||||
|
||||
def install_lazyvim(target: Target) -> Path:
|
||||
paths = lazyvim_paths(target)
|
||||
config_dir = paths["config"]
|
||||
|
||||
if lazyvim_already_installed(config_dir):
|
||||
LOG.info("LazyVim starter already present at %s; skipping clone.",
|
||||
config_dir)
|
||||
return config_dir
|
||||
|
||||
for p in paths.values():
|
||||
backup_path(p)
|
||||
config_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
LOG.info("Cloning LazyVim starter into %s", config_dir)
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "clone", "--depth", "1", LAZYVIM_STARTER_REPO, str(config_dir)],
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise InstallError(f"git clone of LazyVim starter failed: {e}") from e
|
||||
|
||||
git_dir = config_dir / ".git"
|
||||
if git_dir.exists():
|
||||
shutil.rmtree(git_dir, ignore_errors=True)
|
||||
return config_dir
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Telescope override
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
TELESCOPE_OVERRIDE_LUA = '''-- Generated by install_neovim.py
|
||||
-- Show hidden files in Telescope's find_files and live_grep pickers.
|
||||
-- (.git/ is still excluded; remove the --glob/!.git line below to include it.)
|
||||
return {
|
||||
{
|
||||
"nvim-telescope/telescope.nvim",
|
||||
opts = {
|
||||
defaults = {
|
||||
vimgrep_arguments = {
|
||||
"rg",
|
||||
"--color=never",
|
||||
"--no-heading",
|
||||
"--with-filename",
|
||||
"--line-number",
|
||||
"--column",
|
||||
"--smart-case",
|
||||
"--hidden",
|
||||
"--glob=!.git/",
|
||||
},
|
||||
},
|
||||
pickers = {
|
||||
find_files = {
|
||||
hidden = true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
def configure_telescope_hidden(config_dir: Path) -> Path:
|
||||
plugins_dir = config_dir / "lua" / "plugins"
|
||||
plugins_dir.mkdir(parents=True, exist_ok=True)
|
||||
out = plugins_dir / "telescope.lua"
|
||||
if out.is_file():
|
||||
try:
|
||||
current = out.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
current = None
|
||||
if current == TELESCOPE_OVERRIDE_LUA:
|
||||
LOG.info("Telescope override already present at %s; no changes needed.",
|
||||
out)
|
||||
return out
|
||||
LOG.info("Existing %s differs from generated content; overwriting.", out)
|
||||
out.write_text(TELESCOPE_OVERRIDE_LUA, encoding="utf-8")
|
||||
LOG.info("Wrote Telescope override to %s", out)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Orchestration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def setup_logging(verbose: bool) -> None:
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if verbose else logging.INFO,
|
||||
format="[%(levelname)s] %(message)s",
|
||||
)
|
||||
|
||||
|
||||
def print_followup(target: Target, bin_dir: Path, *, lazyvim_skipped: bool) -> None:
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("Neovim install complete.")
|
||||
print("=" * 60)
|
||||
print(f"Binary directory: {bin_dir}")
|
||||
if target.os_name == "windows":
|
||||
print("Open a new terminal window so the updated PATH takes effect, then run:")
|
||||
else:
|
||||
print("Open a new shell or run `source ~/.zshrc`, then run:")
|
||||
print(" nvim --version")
|
||||
if not lazyvim_skipped:
|
||||
print()
|
||||
print("On first launch, LazyVim will bootstrap lazy.nvim and install plugins.")
|
||||
print("Telescope is configured to show hidden files (find_files & live_grep).")
|
||||
print()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Install Neovim + LazyVim with Telescope hidden-files enabled.",
|
||||
)
|
||||
parser.add_argument("-v", "--verbose", action="store_true",
|
||||
help="Enable debug logging.")
|
||||
parser.add_argument("--skip-lazyvim", action="store_true",
|
||||
help="Install only the Neovim binary; skip LazyVim and Telescope steps.")
|
||||
# Internal: set by the script itself when relaunching with UAC elevation
|
||||
# on Windows. Prevents an infinite elevation loop.
|
||||
parser.add_argument("--elevated-relaunch", action="store_true",
|
||||
help=argparse.SUPPRESS)
|
||||
args = parser.parse_args()
|
||||
|
||||
setup_logging(args.verbose)
|
||||
|
||||
# On Windows, request UAC elevation up front so we can install to
|
||||
# Program Files and write the system PATH. If the user declines the
|
||||
# UAC prompt, fall through and install user-local instead.
|
||||
if (platform.system().lower().startswith("win")
|
||||
and not is_admin_windows()
|
||||
and not args.elevated_relaunch):
|
||||
LOG.info("Requesting administrator privileges for system-wide install...")
|
||||
if relaunch_as_admin_windows():
|
||||
LOG.info("Elevated process launched. This window will exit.")
|
||||
return 0
|
||||
LOG.warning("Continuing without elevation; will install for the current user only.")
|
||||
|
||||
require_git()
|
||||
|
||||
target = detect_target()
|
||||
if target is None:
|
||||
LOG.warning("OS auto-detection failed (system=%s, machine=%s).",
|
||||
platform.system(), platform.machine())
|
||||
target = prompt_target()
|
||||
LOG.info("Target: %s/%s", target.os_name, target.arch)
|
||||
|
||||
try:
|
||||
release = http_get_json(NVIM_LATEST_API)
|
||||
except InstallError as e:
|
||||
die(str(e))
|
||||
|
||||
target_tag = release.get("tag_name", "")
|
||||
LOG.info("Latest Neovim release: %s", target_tag or "(unknown)")
|
||||
|
||||
existing = find_existing_install(target, target_tag)
|
||||
if existing is not None:
|
||||
install_dir, system_wide = existing
|
||||
LOG.info("Neovim %s is already installed at %s (%s); "
|
||||
"skipping download and extraction.",
|
||||
target_tag, install_dir,
|
||||
"system-wide" if system_wide else "user-local")
|
||||
if (target.os_name == "windows" and not system_wide
|
||||
and is_admin_windows()):
|
||||
LOG.info("Note: existing install is user-local but the script is "
|
||||
"running with admin rights. To migrate to %s, delete %s "
|
||||
"and re-run.", system_install_dir(target), install_dir)
|
||||
else:
|
||||
try:
|
||||
asset_name, asset_url = find_release_asset(release, target.asset_patterns)
|
||||
except InstallError as e:
|
||||
die(str(e))
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="nvim-install-") as tmp:
|
||||
tmp_path = Path(tmp)
|
||||
archive = tmp_path / asset_name
|
||||
try:
|
||||
http_download(asset_url, archive)
|
||||
extracted = extract_archive(archive, tmp_path / "extracted",
|
||||
target.archive_kind)
|
||||
except InstallError as e:
|
||||
die(str(e))
|
||||
|
||||
try:
|
||||
install_dir, system_wide = place_neovim(extracted, target)
|
||||
except InstallError as e:
|
||||
die(str(e))
|
||||
|
||||
LOG.info("Neovim is at %s (%s).",
|
||||
install_dir, "system-wide" if system_wide else "user-local")
|
||||
|
||||
bin_dir = bin_dir_for(install_dir)
|
||||
try:
|
||||
if target.os_name == "windows":
|
||||
update_path_windows(bin_dir, system_wide=system_wide)
|
||||
else:
|
||||
update_path_unix(bin_dir)
|
||||
except InstallError as e:
|
||||
LOG.error(str(e))
|
||||
LOG.error("Neovim is installed at %s but PATH was not updated. "
|
||||
"Add this directory to your PATH manually.", bin_dir)
|
||||
return 2
|
||||
|
||||
try:
|
||||
verify_neovim(bin_dir, target)
|
||||
except InstallError as e:
|
||||
die(str(e))
|
||||
|
||||
if args.skip_lazyvim:
|
||||
print_followup(target, bin_dir, lazyvim_skipped=True)
|
||||
return 0
|
||||
|
||||
try:
|
||||
config_dir = install_lazyvim(target)
|
||||
configure_telescope_hidden(config_dir)
|
||||
except InstallError as e:
|
||||
LOG.error(str(e))
|
||||
LOG.error("Neovim is installed but LazyVim setup failed. "
|
||||
"Re-run this script or install LazyVim manually.")
|
||||
return 3
|
||||
|
||||
print_followup(target, bin_dir, lazyvim_skipped=False)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit_code = 0
|
||||
try:
|
||||
exit_code = main()
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted.", file=sys.stderr)
|
||||
exit_code = 130
|
||||
finally:
|
||||
# When relaunched via UAC, this script runs in a new console window
|
||||
# that closes the moment the process exits. Pause so the user can
|
||||
# read the result.
|
||||
if "--elevated-relaunch" in sys.argv:
|
||||
try:
|
||||
input("\nPress Enter to close this window...")
|
||||
except EOFError:
|
||||
pass
|
||||
sys.exit(exit_code)
|
||||
Reference in New Issue
Block a user