This commit is contained in:
2026-01-22 00:22:42 -08:00
parent 51593536af
commit 81f29c7025
3 changed files with 234 additions and 85 deletions

View File

@@ -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

View File

@@ -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: