diff --git a/run-client.bat b/run-client.bat new file mode 100644 index 0000000..2cd9430 --- /dev/null +++ b/run-client.bat @@ -0,0 +1,2 @@ +@echo off +"C:\hydrusnetwork\.venv\Scripts\python.exe" "C:\Forgejo\Medios-Macina\scripts\run_client.py" %* diff --git a/scripts/bootstrap.py b/scripts/bootstrap.py index 33f9ce3..7e24b51 100644 --- a/scripts/bootstrap.py +++ b/scripts/bootstrap.py @@ -83,26 +83,38 @@ def _ensure_interactive_stdin() -> None: print(f"DEBUG: Failed to re-open stdin: {e}") -def run(cmd: list[str], quiet: bool = False, debug: bool = False, cwd: Optional[Path] = None, env: Optional[dict[str, str]] = None) -> None: +def run(cmd: list[str], quiet: bool = False, debug: bool = False, cwd: Optional[Path] = None, env: Optional[dict[str, str]] = None, check: bool = True) -> subprocess.CompletedProcess: if debug: print(f"\n> {' '.join(cmd)}") + # Create a copy of the environment to potentially modify it for pip + run_env = env.copy() if env is not None else os.environ.copy() + + # If we are running a python command, ensure we don't leak user site-packages + # which can cause "ModuleNotFoundError: No module named 'attr'" errors in recent pip/rich, + # and ensures we only use packages installed in our venv. + if len(cmd) >= 1 and "python" in str(cmd[0]).lower(): + run_env["PYTHONNOUSERSITE"] = "1" + # Also clear any other potentially conflicting variables + run_env.pop("PYTHONPATH", None) + # Ensure subprocess uses the re-opened interactive stdin if we have one stdin_handle = sys.stdin if not sys.stdin.isatty() or platform.system().lower() == "windows" else None if quiet and not debug: - subprocess.check_call( + return subprocess.run( cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=str(cwd) if cwd else None, - env=env, - stdin=stdin_handle + env=run_env, + stdin=stdin_handle, + check=check ) else: if not debug: print(f"> {' '.join(cmd)}") - subprocess.check_call(cmd, cwd=str(cwd) if cwd else None, env=env, stdin=stdin_handle) + return subprocess.run(cmd, cwd=str(cwd) if cwd else None, env=run_env, stdin=stdin_handle, check=check) REPO_URL = "https://code.glowers.club/goyimnose/Medios-Macina.git" @@ -121,6 +133,7 @@ class ProgressBar: if self.quiet: return + term_width = shutil.get_terminal_size((80, 20)).columns percent = int(100 * (self.current / self.total)) filled = int(self.bar_width * self.current // self.total) @@ -513,28 +526,33 @@ def main() -> int: # Running via pipe/eval, __file__ is not defined script_path = None script_dir = Path.cwd() - repo_root = Path.cwd() + # In Web Installer mode, we don't assume CWD is the repo root. + repo_root = None # DETECT REPOSITORY # Check if we are already inside a valid Medios-Macina repo - def _is_valid_mm_repo(p: Path) -> bool: + def _is_valid_mm_repo(p: Path | None) -> bool: + if p is None: return False return (p / "CLI.py").exists() and (p / "scripts").exists() # If running from a pipe/standalone, don't auto-probe the current directory # as a repo. This prevents "auto-choosing" existing folders in Web Installer mode. + is_in_repo = False if script_path is None: - is_in_repo = False + pass else: - is_in_repo = _is_valid_mm_repo(repo_root) - # If not in the parent of the script, check the current working directory - if not is_in_repo and _is_valid_mm_repo(Path.cwd()): - repo_root = Path.cwd() - script_dir = repo_root / "scripts" - is_in_repo = True + # Check if we are already inside a valid Medios-Macina repo. + # We do this to provide a sensible default if we need to prompt the user. + # But we don't set is_in_repo = True yet, so that _ensure_repo_available + # will still prompt for confirmation in interactive mode. + if not _is_valid_mm_repo(repo_root): + if _is_valid_mm_repo(Path.cwd()): + repo_root = Path.cwd() + script_dir = repo_root / "scripts" - if not args.quiet: + if not args.quiet and args.debug: print(f"Bootstrap script location: {script_dir}") - print(f"Detected project root: {repo_root}") + print(f"Project root: {repo_root}") print(f"Current working directory: {Path.cwd()}") # Helpers for interactive menu and uninstall detection @@ -548,13 +566,18 @@ def main() -> int: def _is_installed() -> bool: """Return True if the project appears installed into the local .venv.""" + if repo_root is None: + return False + vdir = repo_root / ".venv" py = _venv_python_path(vdir) if py is None: return False try: - rc = subprocess.run([str(py), "-m", "pip", "show", "medeia-macina"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) - return rc.returncode == 0 + # We use the global run() with check=False to avoid raising CalledProcessError + # when the package is not found (which returns exit code 1). + res = run([str(py), "-m", "pip", "show", "medeia-macina"], quiet=True, check=False) + return res.returncode == 0 except Exception: return False @@ -745,15 +768,21 @@ def main() -> int: """Show a simple interactive menu to choose install/uninstall or delegate.""" try: installed = _is_installed() - term_width = shutil.get_terminal_size((80, 20)).columns while True: os.system("cls" if os.name == "nt" else "clear") - # Center the logo - logo_lines = LOGO.strip().splitlines() + term_width = shutil.get_terminal_size((80, 20)).columns + + # Use the same centering logic as the main installation screen + logo_lines = LOGO.strip('\n').splitlines() + max_logo_width = 0 + for line in logo_lines: + max_logo_width = max(max_logo_width, len(line.rstrip())) + + logo_padding = ' ' * max((term_width - max_logo_width) // 2, 0) print("\n" * 2) for line in logo_lines: - print(line.center(term_width)) + print(f"{logo_padding}{line.rstrip()}") print("\n") menu_title = " MEDEIA MACINA BOOTSTRAP MENU " @@ -763,17 +792,29 @@ def main() -> int: print(border.center(term_width)) print("\n") - install_text = "1) Reinstall" if installed else "1) Install" - print(install_text.center(term_width)) - print("2) Extras > HydrusNetwork".center(term_width)) + # Define menu options + options = [ + "1) Reinstall" if installed else "1) Install", + "2) Extras > HydrusNetwork" + ] if installed: - print("3) Uninstall".center(term_width)) - print("q) Quit".center(term_width)) + options.append("3) Uninstall") + options.append("4) Install Hydrus System Service (Auto-update + Headless)") + options.append("q) Quit") + + # Center the block of options by finding the longest one + max_opt_width = max(len(opt) for opt in options) + opt_padding = ' ' * max((term_width - max_opt_width) // 2, 0) + + for opt in options: + print(f"{opt_padding}{opt}") prompt = "\nChoose an option: " # Try to center the prompt roughly - indent = " " * ((term_width // 2) - (len(prompt) // 2)) - choice = input(f"{indent}{prompt}").strip().lower() + indent = " " * max((term_width // 2) - (len(prompt) // 2), 0) + sys.stdout.write(f"{indent}{prompt}") + sys.stdout.flush() + choice = sys.stdin.readline().strip().lower() if choice in ("1", "install", "reinstall"): return "install" @@ -784,6 +825,9 @@ def main() -> int: if installed and choice in ("3", "uninstall"): return "uninstall" + if choice == "4": + return "install_service" + if choice in ("q", "quit", "exit"): return 0 except EOFError: @@ -794,16 +838,10 @@ def main() -> int: """Prompt for a clone location when running outside the repository.""" nonlocal repo_root, script_dir, is_in_repo - # If script_path is None, we are running from a pipe/URL. - # In this mode, we ALWAYS want to ask for the directory, - # unless we have already asked and set is_in_repo to True within this session. - if is_in_repo and script_path is not None: + # If we have already settled on a repository path in this session, skip. + if is_in_repo and repo_root is not None: return True - # If we are in standalone mode and is_in_repo is True, it means we - # just finished cloning/setting up the path in the current execution. - # But we need to be careful: if we haven't asked yet, we must ask. - if not shutil.which("git"): print("\nError: 'git' was not found on your PATH.", file=sys.stderr) print("Please install Git (https://git-scm.com/) and try again.", file=sys.stderr) @@ -852,6 +890,7 @@ def main() -> int: return False os.chdir(str(repo_root)) + is_in_repo = True if not args.quiet: print(f"\nSuccessfully set up repository at {repo_root}") print("Resuming bootstrap...\n") @@ -1012,18 +1051,12 @@ def main() -> int: if (sys.stdin.isatty() or sys.stdout.isatty() or script_path is None) and not args.quiet: sel = _interactive_menu() if sel == "install": - # Force set is_in_repo to False if piped to ensure _ensure_repo_available asks - if script_path is None: - is_in_repo = False if not _ensure_repo_available(): return 1 args.skip_deps = False args.install_editable = True args.no_playwright = False elif sel == "extras_hydrus": - # Force set is_in_repo to False if piped to ensure _ensure_repo_available asks - if script_path is None: - is_in_repo = False if not _ensure_repo_available(): return 1 hydrus_script = repo_root / "scripts" / "hydrusnetwork.py" @@ -1049,6 +1082,57 @@ def main() -> int: else: print(f"\nError: {hydrus_script} not found.") return 0 + elif sel == "install_service": + # Direct path input for the target repository + print("\n[ SYSTEM SERVICE INSTALLATION ]") + print("Enter the root directory of the Hydrus repository you want to run as a service.") + print("This is the folder containing 'hydrus_client.py'.") + + # Default to repo_root/hydrusnetwork if available, otherwise CWD + default_path = repo_root / "hydrusnetwork" if repo_root else Path.cwd() + sys.stdout.write(f"Repository Root [{default_path}]: ") + sys.stdout.flush() + + path_raw = sys.stdin.readline().strip() + target_repo = Path(path_raw).resolve() if path_raw else default_path + + if not (target_repo / "hydrus_client.py").exists(): + print(f"\n[!] Error: 'hydrus_client.py' not found in: {target_repo}") + print(" Please ensure you've entered the correct repository root.") + sys.stdout.write("\nPress Enter to return to menu...") + sys.stdout.flush() + sys.stdin.readline() + return "menu" + + run_client_script = repo_root / "scripts" / "run_client.py" if repo_root else Path(__file__).parent / "run_client.py" + + if run_client_script.exists(): + try: + # We pass --repo-root explicitly to the target_repo provided by the user + subprocess.check_call( + [ + sys.executable, + str(run_client_script), + "--install-service", + "--service-name", "hydrus-client", + "--repo-root", str(target_repo), + "--headless", + "--pull" + ], + stdin=sys.stdin + ) + print("\nHydrus System service installed successfully.") + except subprocess.CalledProcessError: + print("\nService installation failed.") + except Exception as e: + print(f"\nError installing service: {e}") + else: + print(f"\nError: {run_client_script} not found.") + + sys.stdout.write("\nPress Enter to continue...") + sys.stdout.flush() + sys.stdin.readline() + return "menu" elif sel == "uninstall": return _do_uninstall() elif sel == "delegate": @@ -1087,6 +1171,10 @@ def main() -> int: print(f"{padding}{line.rstrip()}") print("\n") + if repo_root is None: + print("Error: No project repository found. Please ensure you are running this script inside the project folder or follow the interactive install prompts.", file=sys.stderr) + return 1 + # Determine total steps for progress bar total_steps = 7 # Base: venv, pip, deps, project, cli, finalize, env if args.upgrade_pip: total_steps += 1 @@ -1139,19 +1227,17 @@ def main() -> int: """Ensure pip is available inside the venv; fall back to ensurepip if needed.""" try: - subprocess.run( - [str(python_path), "-m", "pip", "--version"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=True, - ) + # Use run() to ensure clean environment (fixes ModuleNotFoundError: No module named 'attr' in pipe mode) + run([str(python_path), "-m", "pip", "--version"], quiet=True) return except Exception: pass try: - _run_cmd([str(python_path), "-m", "ensurepip", "--upgrade"]) - except subprocess.CalledProcessError: + # ensurepip is a stdlib module, but it can still benefit from a clean environment + # although usually it doesn't use site-packages. + run([str(python_path), "-m", "ensurepip", "--upgrade"], quiet=True) + except Exception: print( "Failed to install pip inside the local virtualenv via ensurepip; ensure your Python build includes ensurepip and retry.", file=sys.stderr, @@ -1239,15 +1325,12 @@ def main() -> int: pb.update("Verifying CLI configuration...") # Check core dependencies (including mpv) before finalizing try: + # Check basic imports and specifically mpv missing = [] for mod in ["importlib", "shutil", "subprocess", "mpv"]: try: - subprocess.run( - [str(venv_python), "-c", f"import {mod}"], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=True - ) + # Use run() for verification to ensure clean environment + run([str(venv_python), "-c", f"import {mod}"], quiet=True) except Exception: missing.append(mod) @@ -1267,17 +1350,11 @@ def main() -> int: pass try: - cli_verify_result = subprocess.run( - [ - str(venv_python), - "-c", - "import importlib; importlib.import_module('CLI')" - ], - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - check=False, - ) - if cli_verify_result.returncode != 0: + # Use run() for CLI verification to ensure clean environment (fixes global interference) + run([str(venv_python), "-c", "import importlib; importlib.import_module('CLI')"], quiet=True) + except Exception: + try: + # If direct import fails, try to diagnose why or add a .pth link cmd = [ str(venv_python), "-c", @@ -1291,7 +1368,7 @@ def main() -> int: "for s in res:\n print(s)\n" ), ] - out = subprocess.check_output(cmd, text=True).strip().splitlines() + out = subprocess.check_output(cmd, text=True, env={**os.environ, "PYTHONNOUSERSITE": "1"}).strip().splitlines() site_dir: Path | None = None for sp in out: if sp and Path(sp).exists(): @@ -1308,6 +1385,8 @@ def main() -> int: else: with pth_file.open("w", encoding="utf-8") as fh: fh.write(content) + except Exception: + pass except Exception: pass diff --git a/scripts/run_client.py b/scripts/run_client.py index 486c85f..23d85b6 100644 --- a/scripts/run_client.py +++ b/scripts/run_client.py @@ -217,6 +217,8 @@ def install_service_windows( headless: bool = True, detached: bool = True, start_on: str = "logon", + pull: bool = False, + workspace_root: Optional[Path] = None, ) -> bool: try: schtasks = shutil.which("schtasks") @@ -226,14 +228,27 @@ def install_service_windows( ) return False - bat = repo_root / "run-client.bat" + # If we have a workspace root (Medios-Macina), we use it for the wrapper scripts + base_dir = workspace_root if workspace_root else repo_root + bat = base_dir / "run-client.bat" + + python_exe = venv_py + this_script = Path(__file__).resolve() + if not bat.exists(): - # Use escaped backslashes to avoid Python "invalid escape sequence" warnings - content = '@echo off\n"%~dp0\\.venv\\Scripts\\python.exe" "%~dp0hydrus_client.py" %*\n' + # Absolute paths ensure the service works regardless of where it starts + content = f'@echo off\n"{python_exe}" "{this_script}" %*\n' bat.write_text(content, encoding="utf-8") tr = str(bat) sc = "ONLOGON" if start_on == "logon" else "ONSTART" + + task_args = "--detached " + if headless: task_args += "--headless " + if pull: task_args += "--pull " + # Force the correct repo root for the service + task_args += f'--repo-root "{repo_root}" ' + cmd = [ schtasks, "/Create", @@ -242,7 +257,7 @@ def install_service_windows( "/TN", service_name, "/TR", - f'"{tr}"', + f'{tr} {task_args}', "/RL", "LIMITED", "/F", @@ -283,7 +298,9 @@ def install_service_systemd( repo_root: Path, venv_py: Path, headless: bool = True, - detached: bool = True + detached: bool = True, + pull: bool = False, + workspace_root: Optional[Path] = None, ) -> bool: try: systemctl = shutil.which("systemctl") @@ -296,15 +313,22 @@ def install_service_systemd( repo_root, venv_py, headless, - detached + detached, + pull=pull, + workspace_root=workspace_root ) unit_dir = Path.home() / ".config" / "systemd" / "user" unit_dir.mkdir(parents=True, exist_ok=True) unit_file = unit_dir / f"{service_name}.service" - exec_args = f'"{venv_py}" "{str(repo_root / "run_client.py")}" --detached ' - exec_args += "--headless " if headless else "--gui " - content = f"[Unit]\nDescription=Hydrus Client (user)\nAfter=network.target\n\n[Service]\nType=simple\nExecStart={exec_args}\nWorkingDirectory={str(repo_root)}\nRestart=on-failure\nEnvironment=PYTHONUNBUFFERED=1\n\n[Install]\nWantedBy=default.target\n" + + this_script = Path(__file__).resolve() + exec_args = f'"{venv_py}" "{this_script}" --detached ' + if headless: exec_args += "--headless " + if pull: exec_args += "--pull " + exec_args += f'--repo-root "{repo_root}" ' + + content = f"[Unit]\nDescription=Medios-Macina Client\nAfter=network.target\n\n[Service]\nType=simple\nExecStart={exec_args}\nWorkingDirectory={str(workspace_root if workspace_root else repo_root)}\nRestart=on-failure\nEnvironment=PYTHONUNBUFFERED=1\n\n[Install]\nWantedBy=default.target\n" unit_file.write_text(content, encoding="utf-8") subprocess.run([systemctl, "--user", "daemon-reload"], check=True) subprocess.run( @@ -356,14 +380,23 @@ def install_service_cron( repo_root: Path, venv_py: Path, headless: bool = True, - detached: bool = True + detached: bool = True, + pull: bool = False, + workspace_root: Optional[Path] = None, ) -> bool: try: crontab = shutil.which("crontab") if not crontab: print("crontab not available; cannot install reboot cron job.") return False - entry = f"@reboot {venv_py} {str(repo_root / 'run_client.py')} --detached {'--headless' if headless else '--gui'} # {service_name}\n" + + this_script = Path(__file__).resolve() + exec_args = f'"{venv_py}" "{this_script}" --detached ' + if headless: exec_args += "--headless " + if pull: exec_args += "--pull " + exec_args += f'--repo-root "{repo_root}" ' + + entry = f"@reboot {exec_args} # {service_name}\n" proc = subprocess.run( [crontab, "-l"], @@ -421,7 +454,9 @@ def install_service_auto( repo_root: Path, venv_py: Path, headless: bool = True, - detached: bool = True + detached: bool = True, + pull: bool = False, + workspace_root: Optional[Path] = None, ) -> bool: try: if os.name == "nt": @@ -430,7 +465,9 @@ def install_service_auto( repo_root, venv_py, headless=headless, - detached=detached + detached=detached, + pull=pull, + workspace_root=workspace_root ) else: if shutil.which("systemctl"): @@ -439,7 +476,9 @@ def install_service_auto( repo_root, venv_py, headless=headless, - detached=detached + detached=detached, + pull=pull, + workspace_root=workspace_root ) else: return install_service_cron( @@ -447,11 +486,16 @@ def install_service_auto( repo_root, venv_py, headless=headless, - detached=detached + detached=detached, + pull=pull, + workspace_root=workspace_root ) except Exception as exc: print("install_service_auto error:", exc) return False + except Exception as exc: + print("install_service_auto error:", exc) + return False def uninstall_service_auto(service_name: str, repo_root: Path, venv_py: Path) -> bool: @@ -602,6 +646,11 @@ def main(argv: Optional[List[str]] = None) -> int: help= "Attempt to launch the client without showing the Qt GUI (best-effort). Default for subsequent runs; first run will show GUI unless --headless is supplied", ) + p.add_argument( + "--pull", + action="store_true", + help="Run 'git pull' before starting the client", + ) p.add_argument( "--gui", action="store_true", @@ -660,6 +709,23 @@ def main(argv: Optional[List[str]] = None) -> int: else: repo_root = workspace_root + # Handle git pull update if requested + if args.pull: + if shutil.which("git"): + if (repo_root / ".git").exists(): + if not args.quiet: + print(f"Updating repository via 'git pull' in {repo_root}...") + try: + subprocess.run(["git", "pull"], cwd=str(repo_root), check=False) + except Exception as e: + print(f"Warning: git pull failed: {e}") + else: + if not args.quiet: + print("Skipping 'git pull': directory is not a git repository.") + else: + if not args.quiet: + print("Skipping 'git pull': 'git' not found on PATH.") + venv_py = find_venv_python(repo_root, args.venv, args.venv_name) def _is_running_in_virtualenv() -> bool: @@ -817,7 +883,9 @@ def main(argv: Optional[List[str]] = None) -> int: repo_root, venv_py, headless=use_headless, - detached=True + detached=True, + pull=args.pull, + workspace_root=workspace_root ) return 0 if ok else 6 if args.uninstall_service: