#!/usr/bin/env python3 import argparse, os, sys, json, shutil, subprocess, tarfile, tempfile, urllib.request, re, time, threading, signal from pathlib import Path DEBUG = False # ========== UI / Spinner ========== class Spinner: FRAMES = ["⠋","⠙","⠹","⠸","⠼","⠴","⠦","⠧","⠇","⠏"] def __init__(self, text): self.text = text self._stop = threading.Event() self._th = threading.Thread(target=self._spin, daemon=True) def _spin(self): i = 0 while not self._stop.is_set(): frame = self.FRAMES[i % len(self.FRAMES)] print(f"\r{frame} {self.text} ", end="", flush=True) time.sleep(0.12) i += 1 def start(self): if not DEBUG: self._th.start() else: print(f"• {self.text} ...") def stop_ok(self): if not DEBUG: self._stop.set() self._th.join(timeout=0.2) print(f"\r✔ {self.text}{' ' * 20}") else: print(f"✔ {self.text}") def stop_fail(self): if not DEBUG: self._stop.set() self._th.join(timeout=0.2) print(f"\r✖ {self.text}{' ' * 20}") else: print(f"✖ {self.text}") def step(text): class _Ctx: def __enter__(self_inner): self_inner.spinner = Spinner(text) self_inner.spinner.start() return self_inner def __exit__(self_inner, exc_type, exc, tb): if exc is None: self_inner.spinner.stop_ok() else: self_inner.spinner.stop_fail() return _Ctx() def _devnull(): return subprocess.DEVNULL if not DEBUG else None def run(cmd, check=True, env=None): if DEBUG: print("+", " ".join(cmd)) return subprocess.run(cmd, check=check, env=env, stdout=None if DEBUG else subprocess.DEVNULL, stderr=None if DEBUG else subprocess.DEVNULL) def run_out(cmd, check=True): if DEBUG: print("+", " ".join(cmd)) result = subprocess.run(cmd, check=check, capture_output=True, text=True) return result.stdout # ========== Utils ========== def ensure_root(): if os.geteuid() != 0: print("Run as root.", file=sys.stderr) sys.exit(1) def apt_update_upgrade(): with step("Updating package lists and system"): run(["apt-get", "update", "-y"] if DEBUG else ["apt-get", "update","-y"]) run(["apt-get", "-y", "upgrade"]) def apt_install(pkgs): if not pkgs: return with step(f"Installing packages: {', '.join(pkgs)}"): run(["apt-get", "install", "-y"] + pkgs) def apt_purge(pkgs): if not pkgs: return with step(f"Removing conflicting packages: {', '.join(pkgs)}"): run(["apt-get", "purge", "-y"] + pkgs, check=False) run(["apt-get", "autoremove", "-y"], check=False) def write_file(path: Path, content: str, mode=0o644): path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") os.chmod(path, mode) def append_unique(path: Path, lines: str): path.parent.mkdir(parents=True, exist_ok=True) existing = path.read_text(encoding="utf-8") if path.exists() else "" out = existing for line in lines.splitlines(): if line.strip() and line not in existing: out += ("" if out.endswith("\n") else "\n") + line + "\n" path.write_text(out, encoding="utf-8") def github_latest_release_tag(repo: str, override: str | None) -> str: if override: return override.lstrip("v") url = f"https://api.github.com/repos/{repo}/releases/latest" with step(f"Downloading from GitGub: {repo}"): with urllib.request.urlopen(url) as r: data = json.load(r) tag = data["tag_name"] return tag.lstrip("v") def download_extract_tar_gz(url: str, dest_dir: Path) -> Path: dest_dir.mkdir(parents=True, exist_ok=True) with step("Downloading and untaring"): with urllib.request.urlopen(url) as r, tempfile.NamedTemporaryFile(delete=False) as tf: shutil.copyfileobj(r, tf) tf.flush() tf_path = Path(tf.name) with tarfile.open(tf_path, "r:gz") as t: try: t.extractall(dest_dir, filter="data") except TypeError: t.extractall(dest_dir) top = t.getmembers()[0].name.split("/")[0] os.unlink(tf_path) return dest_dir / top # === NA GÓRZE pliku, obok innych utili === def sync_backup_nginx_conf(): from pathlib import Path import shutil, filecmp src = Path("/etc/nginx.bak/conf.d") dst = Path("/etc/angie/conf.d") if not src.exists(): return with step("Sync /etc/nginx.bak/conf.d -> /etc/angie/conf.d"): for p in src.rglob("*"): if p.is_dir(): continue rel = p.relative_to(src) target = dst / rel target.parent.mkdir(parents=True, exist_ok=True) try: if not target.exists() or not filecmp.cmp(p, target, shallow=False): shutil.copy2(p, target) except Exception as e: print(f"Warning: sync failed for {p} -> {target}: {e}") def ensure_nginx_symlink(): from pathlib import Path target = Path("/etc/angie") link = Path("/etc/nginx") try: if link.is_symlink() and link.resolve() == target: print("Symlink /etc/nginx -> /etc/angie already in place") return if link.exists() and not link.is_symlink(): backup = Path("/etc/nginx.bak") try: if backup.exists(): if backup.is_symlink() or backup.is_file(): backup.unlink() link.rename(backup) print("Backed up /etc/nginx to /etc/nginx.bak") except Exception as e: print(f"Warning: could not backup /etc/nginx: {e}") try: if link.exists() or link.is_symlink(): link.unlink() except Exception: pass try: link.symlink_to(target) print("Created symlink /etc/nginx -> /etc/angie") except Exception as e: print(f"Warning: could not create /etc/nginx symlink: {e}") except Exception as e: print(f"Warning: symlink check failed: {e}") # ========== Angie / NPM template ========== ANGIE_CONF_TEMPLATE = """# run nginx in foreground #daemon off; load_module /etc/angie/modules/ngx_http_headers_more_filter_module.so; load_module /etc/angie/modules/ngx_http_brotli_filter_module.so; load_module /etc/angie/modules/ngx_http_brotli_static_module.so; load_module /etc/angie/modules/ngx_http_zstd_filter_module.so; load_module /etc/angie/modules/ngx_http_zstd_static_module.so; # other modules include /data/nginx/custom/modules[.]conf; pid /run/angie/angie.pid; user root; worker_processes auto; pcre_jit on; error_log /data/logs/fallback_error.log warn; # Custom include /data/nginx/custom/root_top[.]conf; events { include /data/nginx/custom/events[.]conf; } http { include /etc/angie/mime.types; default_type application/octet-stream; sendfile on; server_tokens off; tcp_nopush on; tcp_nodelay on; client_body_temp_path /tmp/angie/body 1 2; keepalive_timeout 90s; proxy_connect_timeout 90s; proxy_send_timeout 90s; proxy_read_timeout 90s; ssl_prefer_server_ciphers on; gzip on; proxy_ignore_client_abort off; client_max_body_size 2000m; server_names_hash_bucket_size 1024; proxy_http_version 1.1; proxy_set_header X-Forwarded-Scheme $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Accept-Encoding ""; proxy_cache off; proxy_cache_path /var/lib/angie/cache/public levels=1:2 keys_zone=public-cache:30m max_size=192m; proxy_cache_path /var/lib/angie/cache/private levels=1:2 keys_zone=private-cache:5m max_size=1024m; include /etc/angie/conf.d/include/log.conf; include /etc/angie/conf.d/include/resolvers.conf; map $host $forward_scheme { default http; } # Real IP Determination (IPv4 only by default) set_real_ip_from 10.0.0.0/8; set_real_ip_from 172.16.0.0/12; set_real_ip_from 192.168.0.0/16; include /etc/angie/conf.d/include/ip_ranges.conf; real_ip_header X-Real-IP; real_ip_recursive on; # custom brotli off; brotli_comp_level 6; brotli_static on; brotli_types *; zstd on; zstd_min_length 256; zstd_comp_level 3; more_clear_headers "Server"; more_set_headers 'X-by: linuxiarz.pl'; # npm include /data/nginx/custom/http_top[.]conf; include /etc/nginx/conf.d/*.conf; include /data/nginx/default_host/*.conf; include /data/nginx/proxy_host/*.conf; include /data/nginx/redirection_host/*.conf; include /data/nginx/dead_host/*.conf; include /data/nginx/temp/*.conf; include /data/nginx/custom/http[.]conf; } stream { # npm include /data/nginx/stream/*.conf; include /data/nginx/custom/stream[.]conf; } # npm include /data/nginx/custom/root[.]conf; """ ANGIE_UNIT = """[Unit] Description=Angie - high performance web server Documentation=https://en.angie.software/angie/docs/ After=network-online.target remote-fs.target nss-lookup.target Wants=network-online.target [Service] Type=forking PIDFile=/run/angie/angie.pid ExecStart=/usr/sbin/angie -c /etc/angie/angie.conf ExecReload=/bin/sh -c "/bin/kill -s HUP $(/bin/cat /run/angie/angie.pid)" ExecStop=/bin/sh -c "/bin/kill -s TERM $(/bin/cat /run/angie/angie.pid)" Restart=on-failure RestartSec=3s [Install] WantedBy=multi-user.target """ # ========== Angie ========== def setup_angie(): with step("Adding Angie repo and installing Angie packages"): run(["apt-get", "install", "-y", "ca-certificates", "curl", "gnupg"]) run(["curl", "-fsSL", "-o", "/etc/apt/trusted.gpg.d/angie-signing.gpg", "https://angie.software/keys/angie-signing.gpg"]) os_id = run_out(["bash","-lc",". /etc/os-release && echo \"$ID/$VERSION_ID $VERSION_CODENAME\""]).strip() write_file(Path("/etc/apt/sources.list.d/angie.list"), f"deb https://download.angie.software/angie/{os_id} main\n") run(["apt-get", "update"]) run(["apt-get", "install", "-y", "angie", "angie-module-headers-more", "angie-module-brotli", "angie-module-zstd"]) with step("Configuring modules and main Angie config"): modules_dir = Path("/etc/nginx/modules") modules_dir.mkdir(parents=True, exist_ok=True) write_file(Path("/etc/angie/angie.conf"), ANGIE_CONF_TEMPLATE, 0o644) if not Path("/usr/sbin/nginx").exists(): write_file(Path("/usr/sbin/nginx"), "#!/bin/sh\nexec /usr/sbin/angie \"$@\"\n", 0o755) WRAP = """#!/bin/sh # nginx compat -> angie; silent safe config test (-t) # - drop all user -g # - for -t use temp config without 'pid' # - suppress ALL output; return angie's exit code strip_pid_conf() { # find base config from -c if provided, else default local base_conf="/etc/angie/angie.conf" local next_is_c= local arg for arg in "$@"; do if [ -n "$next_is_c" ]; then base_conf="$arg" next_is_c= continue fi [ "$arg" = "-c" ] && next_is_c=1 done # create a temp config with any top-level 'pid ...;' removed local tmpd tmpc tmpd="$(mktemp -d)" tmpc="$tmpd/angie.conf" # remove 'pid ...;' at start of line (optionally indented) sed -E 's/^[[:space:]]*pid[[:space:]]+[^;]+;//' "$base_conf" > "$tmpc" printf '%s\n' "$tmpc" } if printf ' %s ' "$@" | grep -q ' -t '; then # rebuild args without any '-g ' and without '-c ' NEW_ARGS="" skip_next="" for a in "$@"; do if [ -n "$skip_next" ]; then skip_next="" continue fi if [ "$a" = "-g" ] || [ "$a" = "-c" ]; then skip_next=1 continue fi NEW_ARGS="$NEW_ARGS $a" done TMP_CONF="$(strip_pid_conf "$@")" # run test SILENTLY; propagate exit code; cleanup temp # shellcheck disable=SC2086 /usr/sbin/angie -g "pid /tmp/angie-test.pid; error_log off;" -c "$TMP_CONF" $NEW_ARGS >/dev/null 2>&1 rc=$? rm -rf "$(dirname "$TMP_CONF")" exit $rc else exec /usr/sbin/angie "$@" fi """ write_file(Path("/usr/sbin/nginx"), WRAP, 0o755) if not Path("/etc/nginx").exists(): os.symlink("/etc/angie", "/etc/nginx") Path("/etc/nginx/conf.d/include").mkdir(parents=True, exist_ok=True) with step("Setting resolver (IPv4 only) and cache directories"): nameservers = [] for line in Path("/etc/resolv.conf").read_text().splitlines(): if line.strip().startswith("nameserver"): ns = line.split()[1].split("%")[0] if ":" in ns: continue nameservers.append(ns) write_file(Path("/etc/nginx/conf.d/include/resolvers.conf"), f"resolver {' '.join(nameservers)} valid=10s;\n", 0o644) for p in ["/var/lib/angie/cache/public", "/var/lib/angie/cache/private"]: Path(p).mkdir(parents=True, exist_ok=True) os.chmod(p, 0o755) with step("Installing corrected systemd unit for Angie"): write_file(Path("/etc/systemd/system/angie.service"), ANGIE_UNIT, 0o644) def install_certbot_with_dns_plugins(): with step("Installing certbot + DNS plugins"): base = ["certbot"] out = run_out(["bash","-lc","apt-cache search '^python3-certbot-dns-' | awk '{print $1}'"], check=False) or "" dns_pkgs = [p for p in out.splitlines() if p.strip()] run(["apt-get", "install", "-y"] + base + dns_pkgs) def ensure_angie_runtime_perms(): run_path = Path("/run/angie") pid_file = run_path / "angie.pid" run_path.mkdir(parents=True, exist_ok=True) os.chmod(run_path, 0o2775) try: import grp gid = grp.getgrnam("angie").gr_gid os.chown(run_path, -1, gid) except Exception: pass if not pid_file.exists(): pid_file.touch() os.chmod(pid_file, 0o664) try: import grp, pwd gid = grp.getgrnam("angie").gr_gid uid = pwd.getpwnam("root").pw_uid os.chown(pid_file, uid, gid) except Exception: pass def ensure_user_and_dirs(): with step("Creating npm user and app/log directories"): try: run(["id", "-u", "npm"]) except subprocess.CalledProcessError: run(["useradd", "--system", "--home", "/opt/npm", "--create-home", "--shell", "/usr/sbin/nologsn", "npm"]) run(["bash","-lc","getent group angie >/dev/null 2>&1 || groupadd angie"]) run(["bash","-lc","usermod -aG angie npm || true"]) dirs = [ "/data","/data/nginx","/data/custom_ssl","/data/logs","/data/access", "/data/nginx/default_host","/data/nginx/default_www","/data/nginx/proxy_host", "/data/nginx/redirection_host","/data/nginx/stream","/data/nginx/dead_host","/data/nginx/temp", "/data/letsencrypt-acme-challenge","/opt/npm","/opt/npm/frontend","/opt/npm/global", "/run/nginx","/run/angie","/tmp/angie/body" ] for d in dirs: Path(d).mkdir(parents=True, exist_ok=True) run(["bash","-lc","chgrp -h angie /run/angie 2>/dev/null || true"]) os.chmod("/run/angie", 0o2775) Path("/var/log/angie").mkdir(parents=True, exist_ok=True) for f in ["access.log","error.log"]: (Path("/var/log/angie")/f).touch(exist_ok=True) run(["bash","-lc","chgrp -h angie /var/log/angie /var/log/angie/*.log 2>/dev/null || true"]) run(["bash","-lc","chmod 775 /var/log/angie && chmod 664 /var/log/angie/*.log"]) Path("/var/log/nginx").mkdir(parents=True, exist_ok=True) Path("/var/log/nginx/error.log").touch(exist_ok=True) os.chmod("/var/log/nginx/error.log", 0o666) run(["chown","-R","npm:npm","/opt/npm","/data"]) ensure_angie_runtime_perms() def adjust_nginx_like_paths_in_tree(root: Path): for p in root.rglob("*.conf"): try: txt = p.read_text(encoding="utf-8") except Exception: continue txt2 = txt.replace("include conf.d", "include /etc/nginx/conf.d") \ .replace("include /etc/angie/conf.d", "include /etc/nginx/conf.d") if txt2 != txt: p.write_text(txt2, encoding="utf-8") for cand in root.rglob("nginx.conf"): try: txt = cand.read_text(encoding="utf-8") except Exception: continue txt = re.sub(r"^user\s+\S+.*", "user root;", txt, flags=re.M) txt = re.sub(r"^pid\s+.*", "pid /run/angie/angie.pid;", txt, flags=re.M) txt = txt.replace("daemon on;", "#daemon on;") cand.write_text(txt, encoding="utf-8") def install_node_and_yarn(node_pkg: str): apt_install([node_pkg, "yarnpkg"]) if not Path("/usr/bin/yarn").exists() and Path("/usr/bin/yarnpkg").exists(): os.symlink("/usr/bin/yarnpkg","/usr/bin/yarn") def _build_frontend(src_frontend: Path, dest_frontend: Path): with step("Installing frontend dependencies (yarn)"): os.environ["NODE_ENV"] = "development" os.chdir(src_frontend) run(["yarn", "cache", "clean", "--all"]) run(["yarn", "install"]) with step("Building frontend (yarn build)"): env = os.environ.copy() env["NODE_OPTIONS"] = "--openssl-legacy-provider" run(["yarn", "build"], env=env) with step("Copying frontend artifacts"): shutil.copytree(src_frontend / "dist", dest_frontend, dirs_exist_ok=True) if (src_frontend / "app-images").exists(): shutil.copytree(src_frontend / "app-images", dest_frontend / "images", dirs_exist_ok=True) def patch_npm_backend_commands(): candidates = [ Path("/opt/npm/lib/utils.js"), Path("/opt/npm/utils.js"), Path("/opt/npm/lib/commands.js"), ] for p in candidates: if not p.exists(): continue try: txt = p.read_text(encoding="utf-8") except Exception: continue new = re.sub(r'\\blogrotate\\b', '/usr/local/bin/logrotate-npm', txt) new = re.sub(r'(?&1 | awk 'NR==1{print $2}'"], check=False).strip() node_v = run_out(["bash","-lc","node -v | sed 's/^v//'"], check=False).strip() yarn_v = run_out(["bash","-lc","yarn -v || yarnpkg -v"], check=False).strip() return ip, angie_v, node_v, yarn_v, npm_app_version def update_motd(enabled: bool, info, ipv6_enabled: bool): if not enabled: return ip, angie_v, node_v, yarn_v, npm_v = info ipv6_line = "IPv6: enabled (configs untouched)." if ipv6_enabled else "IPv6: disabled in resolvers and conf." creds = "Default login: admin@example.com / changeme" text = f""" ################################ NPM / ANGIE ################################ Nginx Proxy Manager: http://{ip}:81 Angie: v{angie_v} (conf: /etc/angie -> /etc/nginx, reload: angie -s reload) Node.js: v{node_v} Yarn: v{yarn_v} NPM app: v{npm_v} Paths: app=/opt/npm data=/data cache=/var/lib/angie/cache {ipv6_line} {creds} ########################################################################### """ motd_d = Path("/etc/motd.d") if motd_d.exists(): write_file(motd_d / "10-npm-angie", text.strip() + "\n", 0o644) else: motd = Path("/etc/motd") existing = motd.read_text(encoding="utf-8") if motd.exists() else "" pattern = re.compile(r"################################ NPM / ANGIE ################################.*?###########################################################################\n", re.S) if pattern.search(existing): content = pattern.sub(text.strip()+"\n", existing) else: content = (existing.rstrip()+"\n\n"+text.strip()+"\n") if existing else (text.strip()+"\n") write_file(motd, content, 0o644) def print_summary(info, ipv6_enabled, dark_enabled, update_mode): ip, angie_v, node_v, yarn_v, npm_v = info print("\n====================== SUMMARY ======================") print(f"Mode: {'UPDATE' if update_mode else 'INSTALL'}") print(f"NPM panel address: http://{ip}:81") print(f"Angie: v{angie_v} (unit: angie.service, PID: /run/angie/angie.pid)") print(f"Node.js: v{node_v}") print(f"Yarn: v{yarn_v}") print(f"NPM (aplikacja): v{npm_v}") print(f"IPv6: {'ENABLED' if ipv6_enabled else 'DISABLED (in confogs too)'}") print(f"Dark mode (TP): {'YES' if dark_enabled else 'NO'}") print("Paths: /opt/npm (app), /data (data), /etc/angie (conf), /var/log/angie (logs)") print("Services: systemctl status angie.service / npm.service") print("Default login: Email: admin@example.com Password: changeme") print("Test config: /usr/sbin/nginx -t (test PID: /tmp/angie-test.pid)") print("==========================================================\n") # ========== UPDATE-ONLY ========== def update_only(node_pkg: str, npm_version_override: str | None, apply_dark: bool, dark_env: dict): apt_update_upgrade() install_node_and_yarn(node_pkg) version = github_latest_release_tag("NginxProxyManager/nginx-proxy-manager", npm_version_override) url = f"https://codeload.github.com/NginxProxyManager/nginx-proxy-manager/tar.gz/refs/tags/v{version}" tmp = Path(tempfile.mkdtemp(prefix="npm-update-")) src = download_extract_tar_gz(url, tmp) with step("Setting version in package.json (update)"): for pkg in ["backend/package.json", "frontend/package.json"]: pj = src / pkg txt = pj.read_text(encoding="utf-8") txt = re.sub(r'"version":\s*"0\.0\.0"', f'"version": "{version}"', txt) pj.write_text(txt, encoding="utf-8") _build_frontend(src / "frontend", Path("/opt/npm/frontend")) with step("Updating backend without overwriting config/"): backup_cfg = Path("/tmp/npm-config-backup") if backup_cfg.exists(): shutil.rmtree(backup_cfg) if Path("/opt/npm/config").exists(): shutil.copytree("/opt/npm/config", backup_cfg, dirs_exist_ok=True) for item in Path("/opt/npm").glob("*"): if item.name in ("frontend","config"): continue if item.is_dir(): shutil.rmtree(item) else: item.unlink() shutil.copytree(src / "backend", "/opt/npm", dirs_exist_ok=True) Path("/opt/npm/config").mkdir(parents=True, exist_ok=True) if backup_cfg.exists(): shutil.copytree(backup_cfg, "/opt/npm/config", dirs_exist_ok=True) shutil.rmtree(backup_cfg, ignore_errors=True) with step("Installing backend dependencies after update"): os.chdir("/opt/npm") run(["yarn", "install"]) with step("Setting owners"): run(["chown","-R","npm:npm","/opt/npm"]) if apply_dark: apply_dark_mode(**dark_env) with step("Restarting services after update"): run(["systemctl","restart","angie.service"], check=False) run(["systemctl","restart","npm.service"], check=False) return version # ========== DARK MODE ========== def apply_dark_mode(APP_FILEPATH="/opt/npm/frontend", TP_DOMAIN=None, TP_COMMUNITY_THEME=None, TP_SCHEME=None, TP_THEME=None): print('--------------------------------------') print('| Nginx Proxy Manager theme.park Mod |') print('--------------------------------------') if not Path(APP_FILEPATH).exists(): if Path("/app/frontend").exists(): APP_FILEPATH = "/app/frontend" elif Path("/opt/nginx-proxy-manager/frontend").exists(): APP_FILEPATH = "/opt/nginx-proxy-manager/frontend" if not TP_DOMAIN or TP_DOMAIN.strip() == "": print("No domain set, defaulting to theme-park.dev") TP_DOMAIN = "theme-park.dev" if not TP_SCHEME or TP_SCHEME.strip() == "": print("No scheme set, defaulting to https") TP_SCHEME = "https" THEME_TYPE = "community-theme-options" if (str(TP_COMMUNITY_THEME).lower() == "true") else "theme-options" if not TP_THEME or TP_THEME.strip() == "": print("No theme set, defaulting to organizr") TP_THEME = "organizr" if "github.io" in TP_DOMAIN: TP_DOMAIN = f"{TP_DOMAIN}/theme.park" print("Variables set:\n" f"'APP_FILEPATH'={APP_FILEPATH}\n" f"'TP_DOMAIN'={TP_DOMAIN}\n" f"'TP_COMMUNITY_THEME'={TP_COMMUNITY_THEME}\n" f"'TP_SCHEME'={TP_SCHEME}\n" f"'TP_THEME'={TP_THEME}\n") base_href = f"{TP_SCHEME}://{TP_DOMAIN}/css/base/nginx-proxy-manager/nginx-proxy-manager-base.css" theme_href = f"{TP_SCHEME}://{TP_DOMAIN}/css/{THEME_TYPE}/{TP_THEME}.css" with step("Wstrzykuję arkusze stylów Theme.Park do HTML"): htmls = list(Path(APP_FILEPATH).rglob("*.html")) for path in htmls: html = path.read_text(encoding="utf-8") if base_href not in html: html = re.sub(r"", f" ", html, flags=re.I) html = re.sub(r"", f" ", html, flags=re.I) path.write_text(html, encoding="utf-8") if DEBUG: print(f"Patched: {path}") # ========== MAIN ========== def main(): global DEBUG ensure_root() parser = argparse.ArgumentParser( description="Install/upgrade NPM on Angie (Debian 13) with step animation.", formatter_class=argparse.ArgumentDefaultsHelpFormatter ) parser.add_argument("--nodejs-pkg", default="nodejs", help="APT Node.js package name (e.g. nodejs, nodejs-18).") parser.add_argument("--npm-version", default=None, help="Force NPM app version (e.g. 2.12.6). Default: latest release.") parser.add_argument("--motd", choices=["yes","no"], default="yes", help="Update MOTD after completion.") parser.add_argument("--enable-ipv6", action="store_true", help="Do not strip IPv6 from configs/resolvers (keep IPv6).") parser.add_argument("--update", action="store_true", help="Update mode: upgrade packages + rebuild frontend/backend without reconfiguring Angie.") parser.add_argument("--dark-mode", action="store_true", help="Inject Theme.Park CSS into frontend (see TP_* vars).") parser.add_argument("--tp-domain", default=os.environ.get("TP_DOMAIN", ""), help="Theme.Park domain (e.g. theme-park.dev or *.github.io).") parser.add_argument("--tp-community-theme", default=os.environ.get("TP_COMMUNITY_THEME", "false"), help="true = community-theme-options; false = theme-options.") parser.add_argument("--tp-scheme", default=os.environ.get("TP_SCHEME", "https"), help="URL scheme (http/https).") parser.add_argument("--tp-theme", default=os.environ.get("TP_THEME", "organizr"), help="Theme.Park theme name (e.g. organizr, catppuccin).") parser.add_argument("--debug", action="store_true", help="Show detailed logs and progress.") args = parser.parse_args() DEBUG = args.debug print("\n================== NPM + ANGIE installer ( https://gitea.linuxiarz.pl/gru/npm-angie-auto-install ) ==================") print("Log mode:", "DEBUG" if DEBUG else "SIMPLE") print("\n@linuxiarz.pl\n") if args.update: install_logrotate_for_data_logs() fix_logrotate_permissions_and_wrapper() version = update_only( node_pkg=args.nodejs_pkg, npm_version_override=args.npm_version, apply_dark=args.dark_mode, dark_env=dict( APP_FILEPATH="/opt/npm/frontend", TP_DOMAIN=args.tp_domain, TP_COMMUNITY_THEME=args.tp_community_theme, TP_SCHEME=args.tp_scheme, TP_THEME=args.tp_theme, ) ) info = gather_versions(version) update_motd(args.motd == "yes", info, ipv6_enabled=args.enable_ipv6) print_summary(info, args.enable_ipv6, args.dark_mode, update_mode=True) return apt_update_upgrade() apt_purge(["nginx","openresty","nodejs","npm","yarn","certbot","rustc","cargo"]) apt_install(["ca-certificates","curl","gnupg","openssl","apache2-utils","logrotate", "python3","python3-venv","sqlite3","build-essential"]) setup_angie() install_certbot_with_dns_plugins() install_node_and_yarn(args.nodejs_pkg) ensure_user_and_dirs() npm_app_version = deploy_npm_app(args.npm_version) if not args.enable_ipv6: strip_ipv6_listens([Path("/etc/angie"), Path("/etc/nginx")]) else: print("IPv6: leaving entries (skipped IPv6 cleanup).") if args.dark_mode: apply_dark_mode(APP_FILEPATH="/opt/npm/frontend", TP_DOMAIN=args.tp_domain, TP_COMMUNITY_THEME=args.tp_community_theme, TP_SCHEME=args.tp_scheme, TP_THEME=args.tp_theme) create_systemd_units() ensure_nginx_symlink() install_logrotate_for_data_logs() fix_logrotate_permissions_and_wrapper() sync_backup_nginx_conf() with step("Restarting services after installation"): run(["systemctl","restart","angie.service"], check=False) run(["systemctl","restart","npm.service"], check=False) info = gather_versions(npm_app_version) update_motd(args.motd == "yes", info, ipv6_enabled=args.enable_ipv6) print_summary(info, args.enable_ipv6, args.dark_mode, update_mode=False) if __name__ == "__main__": signal.signal(signal.SIGINT, lambda s, f: sys.exit(130)) main()