Files
Medios-Macina/scripts/bootstrap.py
2026-01-31 20:24:15 -08:00

1954 lines
87 KiB
Python

#!/usr/bin/env python3
"""scripts/bootstrap.py
Unified project bootstrap helper (Python-only).
This script installs Python dependencies from `scripts/requirements.txt` and then
downloads Playwright browser binaries by running `python -m playwright install`.
By default this script installs **Chromium** only to conserve space; pass
`--browsers all` to install all supported engines (chromium, firefox, webkit).
FFmpeg: The project includes ffmpeg binaries for Windows (in MPV/ffmpeg). On Linux/macOS,
install ffmpeg using your system package manager (apt install ffmpeg, brew install ffmpeg, etc.).
ffmpeg-python is installed as a dependency, but requires ffmpeg itself to be on your PATH.
Note: This Python script is the canonical installer for the project — prefer
running `python ./scripts/bootstrap.py` locally. The platform scripts
(`scripts/bootstrap.ps1` and `scripts/bootstrap.sh`) are now thin wrappers
that delegate to this script (they call it with `--no-delegate -q`).
When invoked without any arguments, `bootstrap.py` will automatically select and
run the platform-specific bootstrap helper (`scripts/bootstrap.ps1` on Windows
or `scripts/bootstrap.sh` on POSIX) in **non-interactive (quiet)** mode so a
single `python ./scripts/bootstrap.py` call does the usual bootstrap on your OS.
The platform bootstrap scripts also attempt (best-effort) to install `mpv` if
it is not found on your PATH, since some workflows use it.
This file replaces the old `scripts/setup.py` to ensure the repository only has
one `setup.py` (at the repository root) for packaging.
Usage:
python ./scripts/bootstrap.py # install deps and playwright browsers (or run platform bootstrap if no args)
python ./scripts/bootstrap.py --skip-deps
python ./scripts/bootstrap.py --playwright-only
Optional flags:
--skip-deps Skip `pip install -r scripts/requirements.txt` step
--no-playwright Skip running `python -m playwright install` (still installs deps)
--playwright-only Install only Playwright browsers (installs playwright package if missing)
--browsers Comma-separated list of Playwright browsers to install (default: chromium)
--install-editable Install the project in editable mode (pip install -e scripts) for running tests
--install-mpv Install MPV player if not already installed (default)
--no-mpv Skip installing MPV player
--install-deno Install the Deno runtime using the official installer (default)
--no-deno Skip installing the Deno runtime
--deno-version Pin a specific Deno version to install (e.g., v1.34.3)
--upgrade-pip Upgrade pip, setuptools, and wheel before installing deps
--check-install Verify that the 'mm' command was installed correctly
--debug Show detailed diagnostic information during installation
--quiet Suppress output (used internally by platform scripts)
"""
from __future__ import annotations
import argparse
import os
import platform
import re
import tempfile
import urllib.request
from pathlib import Path
import shutil
import subprocess
import sys
import time
from typing import Optional
def _ensure_interactive_stdin() -> None:
"""If stdin is piped (e.g. via curl | python), re-open it to the terminal."""
if not sys.stdin.isatty():
try:
if platform.system().lower() == "windows":
# Ensure the handle is actually opened for reading correctly
new_stdin = open("CONIN$", "r")
sys.stdin = new_stdin
else:
sys.stdin = open("/dev/tty", "r")
# Flush existing buffers to ensure clean state
if hasattr(sys.stdin, 'flush'):
sys.stdin.flush()
except Exception as e:
if "--debug" in sys.argv:
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, 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:
return subprocess.run(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
cwd=str(cwd) if cwd else None,
env=run_env,
stdin=stdin_handle,
check=check
)
else:
if not debug:
print(f"> {' '.join(cmd)}")
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"
HYDRUS_REPO_URL = "https://github.com/hydrusnetwork/hydrus.git"
HYDRUS_INSTALLER_SCRIPT_URLS = (
"https://code.glowers.club/goyimnose/Medios-Macina/raw/branch/main/scripts/hydrusnetwork.py",
)
class ProgressBar:
def __init__(self, total: int, quiet: bool = False):
self.total = total
self.current = 0
self.quiet = quiet
self.bar_width = 40
def update(self, step_name: str):
if self.current < self.total:
self.current += 1
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)
bar = "" * filled + "" * (self.bar_width - filled)
# Overwrite previous bar/label by moving up if not the first step
if self.current > 1:
sys.stdout.write("\033[2A")
bar_line = f"[{bar}]"
info_line = f"{percent}% | {step_name}"
sys.stdout.write(f"\r{bar_line.center(term_width)}\n")
# Clear line and print info line centered
sys.stdout.write(f"\r\033[K{info_line.center(term_width)}\r")
sys.stdout.flush()
if self.current == self.total:
sys.stdout.write("\n")
sys.stdout.flush()
LOGO = r"""
███╗ ███╗███████╗██████╗ ███████╗██╗ █████╗ ███╗ ███╗ █████╗ ██████╗██╗███╗ ██╗ █████╗
████╗ ████║██╔════╝██╔══██╗██╔════╝██║██╔══██╗ ████╗ ████║██╔══██╗██╔════╝██║████╗ ██║██╔══██╗
██╔████╔██║█████╗ ██║ ██║█████╗ ██║███████║ ██╔████╔██║███████║██║ ██║██╔██╗ ██║███████║
██║╚██╔╝██║██╔══╝ ██║ ██║██╔══╝ ██║██╔══██║ ██║╚██╔╝██║██╔══██║██║ ██║██║╚██╗██║██╔══██║
██║ ╚═╝ ██║███████╗██████╔╝███████╗██║██║ ██║ ██║ ╚═╝ ██║██║ ██║╚██████╗██║██║ ╚████║██║ ██║
╚═╝ ╚═╝╚══════╝╚═════╝ ╚══════╝╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝
< ΓΝΩΘΙ ΣΕΑΥΤΟΝ | TEMET NOSCE | KNOW THYSELF >
0123456789123456789123456789123456789123456789
0246813579246813579246813579246813579246813579
0369369369369369369369369369369369369369369369
0483726159483726159483726159483726159483726159
0516273849516273849516273849516273849516273849
0639639639639639639639639639639639639639639639
0753816429753816429753816429753816429753816429
0876543219876543219876543219876543219876543219
0999999999999999999999999999999999999999999999
< ALL WITHIN ARE KNOW | ABLE ALL ARE WITHOUT >
"""
# Helpers to find shell executables and to run the platform-specific
# bootstrap script (scripts/bootstrap.sh or scripts/bootstrap.ps1).
def _find_powershell() -> str | None:
for name in ("pwsh", "powershell"):
p = shutil.which(name)
if p:
return p
return None
def _find_shell() -> str | None:
for name in ("bash", "sh"):
p = shutil.which(name)
if p:
return p
return None
def run_platform_bootstrap(repo_root: Path) -> int:
"""Run the platform bootstrap script in quiet/non-interactive mode if present.
Returns the script exit code (0 on success). If no script is present this is a
no-op and returns 0.
"""
ps1 = repo_root / "scripts" / "bootstrap.ps1"
sh_script = repo_root / "scripts" / "bootstrap.sh"
system = platform.system().lower()
if system == "windows" and ps1.exists():
exe = _find_powershell()
if not exe:
print("PowerShell not found; cannot run bootstrap.ps1", file=sys.stderr)
return 1
cmd = [
exe,
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-File",
str(ps1),
"-Quiet",
]
elif sh_script.exists():
shell = _find_shell()
if not shell:
print("Shell not found; cannot run bootstrap.sh", file=sys.stderr)
return 1
# Use -q (quiet) to skip interactive prompts when supported.
cmd = [shell, str(sh_script), "-q"]
else:
# Nothing to run
return 0
print("Running platform bootstrap script:", " ".join(cmd))
rc = subprocess.run(cmd, cwd=str(repo_root))
if rc.returncode != 0:
print(
f"Bootstrap script failed with exit code {rc.returncode}",
file=sys.stderr
)
return int(rc.returncode or 0)
def playwright_package_installed() -> bool:
try:
return True
except Exception:
return False
def _build_playwright_install_cmd(browsers: str | None) -> list[str]:
"""Return the command to install Playwright browsers.
- If browsers is None or empty: default to install Chromium only (headless).
- If browsers contains 'all': install all engines by running 'playwright install' with no extra args.
- Otherwise, validate entries and return a command that installs the named engines.
The --with-deps flag is NOT used because:
1. The project already includes ffmpeg (in MPV/ffmpeg)
2. Most system dependencies should already be available
"""
# Use --skip-browsers to just install deps without browsers, then install specific browsers
base = [sys.executable, "-m", "playwright", "install"]
if not browsers:
return base + ["chromium"]
items = [b.strip().lower() for b in browsers.split(",") if b.strip()]
if not items:
return base + ["chromium"]
if "all" in items:
return base
allowed = {"chromium",
"firefox",
"webkit"}
invalid = [b for b in items if b not in allowed]
if invalid:
raise ValueError(
f"invalid browsers specified: {invalid}. Valid choices: chromium, firefox, webkit, or 'all'"
)
return base + items
def _check_deno_installed() -> bool:
"""Check if Deno is already installed and accessible in PATH."""
return shutil.which("deno") is not None
def _check_mpv_installed() -> bool:
"""Check if MPV is already installed and accessible in PATH."""
return shutil.which("mpv") is not None
def _install_mpv() -> int:
"""Install MPV player for the current platform.
Returns exit code 0 on success, non-zero otherwise.
"""
system = platform.system().lower()
try:
if system == "windows":
# Windows: use winget (built-in package manager)
if shutil.which("winget"):
print("Installing MPV via winget...")
run(["winget", "install", "--id=mpv.net", "-e"])
else:
print(
"MPV not found and winget not available.\n"
"Please install MPV manually from https://mpv.io/installation/",
file=sys.stderr
)
return 1
elif system == "darwin":
# macOS: use Homebrew
if shutil.which("brew"):
print("Installing MPV via Homebrew...")
run(["brew", "install", "mpv"])
else:
print(
"MPV not found and Homebrew not available.\n"
"Install Homebrew from https://brew.sh then run: brew install mpv",
file=sys.stderr
)
return 1
else:
# Linux: use apt, dnf, or pacman
if shutil.which("apt"):
print("Installing MPV via apt...")
run(["sudo", "apt", "install", "-y", "mpv"])
elif shutil.which("dnf"):
print("Installing MPV via dnf...")
run(["sudo", "dnf", "install", "-y", "mpv"])
elif shutil.which("pacman"):
print("Installing MPV via pacman...")
run(["sudo", "pacman", "-S", "mpv"])
else:
print(
"MPV not found and no recognized package manager available.\n"
"Please install MPV manually for your distribution.",
file=sys.stderr
)
return 1
# Verify installation
if shutil.which("mpv"):
print(f"MPV installed at: {shutil.which('mpv')}")
return 0
print("MPV installation completed but 'mpv' not found in PATH.", file=sys.stderr)
return 1
except subprocess.CalledProcessError as exc:
print(f"MPV install failed: {exc}", file=sys.stderr)
return int(exc.returncode or 1)
except Exception as exc:
print(f"MPV install error: {exc}", file=sys.stderr)
return 1
def _install_deno(version: str | None = None) -> int:
"""Install Deno runtime for the current platform.
Uses the official Deno install scripts:
- Unix/macOS: curl -fsSL https://deno.land/x/install/install.sh | sh [-s <version>]
- Windows: powershell iwr https://deno.land/x/install/install.ps1 -useb | iex; Install-Deno [-Version <version>]
Returns exit code 0 on success, non-zero otherwise.
"""
system = platform.system().lower()
try:
if system == "windows":
# Use official PowerShell installer
if version:
ver = version if version.startswith("v") else f"v{version}"
ps_cmd = f"iwr https://deno.land/x/install/install.ps1 -useb | iex; Install-Deno -Version {ver}"
else:
ps_cmd = "iwr https://deno.land/x/install/install.ps1 -useb | iex"
run(
[
"powershell",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
ps_cmd
]
)
else:
# POSIX: use curl + sh installer
if version:
ver = version if version.startswith("v") else f"v{version}"
cmd = f"curl -fsSL https://deno.land/x/install/install.sh | sh -s {ver}"
else:
cmd = "curl -fsSL https://deno.land/x/install/install.sh | sh"
run(["sh", "-c", cmd])
# Check that 'deno' is now available in PATH
if shutil.which("deno"):
print(f"Deno installed at: {shutil.which('deno')}")
return 0
print(
"Deno installation completed but 'deno' not found in PATH. You may need to add Deno's bin directory to your PATH manually.",
file=sys.stderr,
)
return 1
except subprocess.CalledProcessError as exc:
print(f"Deno install failed: {exc}", file=sys.stderr)
return int(exc.returncode or 1)
def main() -> int:
# Ensure interactive stdin if piped
_ensure_interactive_stdin()
parser = argparse.ArgumentParser(
description="Bootstrap Medios-Macina: install deps and Playwright browsers"
)
parser.add_argument(
"--skip-deps",
action="store_true",
help="Skip installing Python dependencies from scripts/requirements.txt",
)
parser.add_argument(
"--no-playwright",
action="store_true",
help="Skip running 'playwright install' (only install packages)",
)
parser.add_argument(
"--playwright-only",
action="store_true",
help="Only run 'playwright install' (skips dependency installation)",
)
parser.add_argument(
"--no-delegate",
action="store_true",
help="Do not delegate to platform bootstrap scripts; run the Python bootstrap directly.",
)
parser.add_argument(
"-q",
"--quiet",
action="store_true",
help="Quiet mode: minimize informational output (useful when called from platform wrappers)",
)
parser.add_argument(
"--browsers",
type=str,
default="chromium",
help=
"Comma-separated list of browsers to install: chromium,firefox,webkit or 'all' (default: chromium)",
)
parser.add_argument(
"--install-editable",
action="store_true",
help="Install the project in editable mode (pip install -e scripts) for running tests",
)
mpv_group = parser.add_mutually_exclusive_group()
mpv_group.add_argument(
"--install-mpv",
action="store_true",
help="Install MPV player if not already installed (default behavior)",
)
mpv_group.add_argument(
"--no-mpv",
action="store_true",
help="Skip installing MPV player (opt out)"
)
deno_group = parser.add_mutually_exclusive_group()
deno_group.add_argument(
"--install-deno",
action="store_true",
help="Install the Deno runtime (default behavior; kept for explicitness)",
)
deno_group.add_argument(
"--no-deno",
action="store_true",
help="Skip installing Deno runtime (opt out)"
)
parser.add_argument(
"--deno-version",
type=str,
default=None,
help="Specific Deno version to install (e.g., v1.34.3)",
)
parser.add_argument(
"--upgrade-pip",
action="store_true",
help="Upgrade pip/setuptools/wheel before installing requirements",
)
parser.add_argument(
"--debug",
action="store_true",
help="Show detailed diagnostic information during installation",
)
parser.add_argument(
"--check-install",
action="store_true",
help="Verify that the 'mm' command was installed correctly",
)
parser.add_argument(
"--uninstall",
action="store_true",
help="Uninstall local .venv and user shims (non-interactive)",
)
parser.add_argument(
"-y",
"--yes",
action="store_true",
help="Assume yes for confirmation prompts during uninstall",
)
args = parser.parse_args()
# Ensure repo_root is always the project root, not the current working directory
# This prevents issues when bootstrap.py is run from different directories
try:
script_path = Path(__file__).resolve()
script_dir = script_path.parent
repo_root = script_dir.parent
except NameError:
# Running via pipe/eval, __file__ is not defined
script_path = None
script_dir = 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 | None) -> bool:
if p is None: return False
return (p / "CLI.py").exists() and (p / "scripts").exists()
# Detect if we are already inside a valid Medios-Macina repo
is_in_repo = _is_valid_mm_repo(repo_root)
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
# If running from a pipe/standalone (Web Installer Mode), we force is_in_repo = False
# so that Option 1 will always provide the path prompt rather than auto-detecting.
if script_path is None:
is_in_repo = False
if not args.quiet and args.debug:
print(f"Bootstrap script location: {script_dir}")
print(f"Project root: {repo_root}")
print(f"Current working directory: {Path.cwd()}")
print(f"Is in repo: {is_in_repo}")
# Helpers for interactive menu and uninstall detection
def _venv_python_path(p: Path) -> Path | None:
"""Return the path to a python executable inside a venv directory if present."""
if (p / "Scripts" / "python.exe").exists():
return p / "Scripts" / "python.exe"
if (p / "bin" / "python").exists():
return p / "bin" / "python"
return None
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:
# 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
def _do_uninstall() -> int:
"""Attempt to remove the local venv and any shims written to the user's bin.
If this script is running using the Python inside the local `.venv`, we
attempt to re-run the uninstall using a Python interpreter outside the
venv (so files can be removed on Windows). If no suitable external
interpreter can be found, the user is asked to deactivate the venv and
re-run the uninstall.
"""
vdir = repo_root / ".venv"
if not vdir.exists():
if not args.quiet:
print("No local .venv found; nothing to uninstall.")
return 0
# If the current interpreter is the one inside the local venv, try to
# run the uninstall via a Python outside the venv so files (including
# the interpreter binary) can be removed on Windows.
current_exe = Path(sys.executable).resolve()
try:
in_venv = str(current_exe).lower().startswith(str(vdir.resolve()).lower())
except Exception:
in_venv = False
if in_venv:
if not args.quiet:
print(f"Detected local venv Python in use: {current_exe}")
if not args.yes:
try:
resp = input("Uninstall will be attempted using a system Python outside the .venv. Continue? [Y/n]: ")
except EOFError:
print("Non-interactive environment; pass --uninstall --yes to uninstall without prompts.", file=sys.stderr)
return 2
if resp.strip().lower() in ("n", "no"):
print("Uninstall aborted.")
return 1
def _find_external_python() -> list[str] | None:
"""Return a command (list) for a Python interpreter outside the venv, or None."""
try:
base = Path(sys.base_prefix)
candidates: list[Path | str] = []
if platform.system().lower() == "windows":
candidates.append(base / "python.exe")
else:
candidates.extend([base / "bin" / "python3", base / "bin" / "python"])
for name in ("python3", "python"):
p = shutil.which(name)
if p:
candidates.append(Path(p))
# Special-case the Windows py launcher: ensure it resolves
# to a Python outside the venv before returning ['py','-3']
if platform.system().lower() == "windows":
py_launcher = shutil.which("py")
if py_launcher:
try:
out = subprocess.check_output(["py", "-3", "-c", "import sys; print(sys.executable)"], text=True).strip()
if out and not str(Path(out).resolve()).lower().startswith(str(vdir.resolve()).lower()):
return ["py", "-3"]
except Exception:
pass
for c in candidates:
try:
if isinstance(c, Path) and c.exists():
c_resolved = Path(c).resolve()
if not str(c_resolved).lower().startswith(str(vdir.resolve()).lower()) and c_resolved != current_exe:
return [str(c_resolved)]
except Exception:
continue
except Exception:
pass
return None
ext = _find_external_python()
if ext:
cmd = ext + [str(repo_root / "scripts" / "bootstrap.py"), "--uninstall", "--yes"]
if not args.quiet:
print("Attempting uninstall using external Python:", " ".join(cmd))
rc = subprocess.run(cmd)
if rc.returncode != 0:
print(
f"External uninstall exited with {rc.returncode}; ensure no processes are using files in {vdir} and try again.",
file=sys.stderr,
)
return int(rc.returncode or 0)
print(
"Could not find a Python interpreter outside the local .venv. Please deactivate your venv (run 'deactivate') or run the uninstall from a system Python:\n python ./scripts/bootstrap.py --uninstall --yes",
file=sys.stderr,
)
return 2
# Normal (non-venv) uninstall flow: confirm and remove launchers, shims, and venv
if not args.yes:
try:
prompt = input(f"Remove local virtualenv at {vdir} and installed user shims? [y/N]: ")
except EOFError:
print("Non-interactive environment; pass --uninstall --yes to uninstall without prompts.", file=sys.stderr)
return 2
if prompt.strip().lower() not in ("y", "yes"):
print("Uninstall aborted.")
return 1
# Remove repo-local launchers
def _remove_launcher(path: Path) -> None:
if path.exists():
try:
path.unlink()
if not args.quiet:
print(f"Removed local launcher: {path}")
except Exception as exc:
print(f"Warning: failed to remove {path}: {exc}", file=sys.stderr)
scripts_launcher = repo_root / "scripts" / "mm.ps1"
_remove_launcher(scripts_launcher)
for legacy in ("mm", "mm.ps1", "mm.bat"):
_remove_launcher(repo_root / legacy)
# Remove user shims that the installer may have written
try:
system = platform.system().lower()
if system == "windows":
user_bin = Path(os.environ.get("USERPROFILE", str(Path.home()))) / "bin"
if user_bin.exists():
for name in ("mm.ps1",):
p = user_bin / name
if p.exists():
try:
p.unlink()
if not args.quiet:
print(f"Removed user shim: {p}")
except Exception as exc:
print(f"Warning: failed to remove {p}: {exc}", file=sys.stderr)
else:
user_bin = Path(os.environ.get("XDG_BIN_HOME", str(Path.home() / ".local/bin")))
if user_bin.exists():
p = user_bin / "mm"
if p.exists():
p.unlink()
if not args.quiet:
print(f"Removed user shim: {p}")
except Exception as exc:
print(f"Warning: failed to remove user shims: {exc}", file=sys.stderr)
# Remove .venv directory
try:
shutil.rmtree(vdir)
if not args.quiet:
print(f"Removed local virtualenv: {vdir}")
except Exception as exc:
print(f"Failed to remove venv: {exc}", file=sys.stderr)
return 1
return 0
def _update_config_value(root: Path, key: str, value: str) -> bool:
db_path = root / "medios.db"
config_path = root / "config.conf"
# Try database first
if db_path.exists():
try:
import sqlite3
with sqlite3.connect(str(db_path), timeout=30.0) as conn:
conn.row_factory = sqlite3.Row
cur = conn.cursor()
# Find all existing hydrusnetwork store names
cur.execute(
"SELECT DISTINCT item_name FROM config WHERE category='store' AND subtype='hydrusnetwork'"
)
rows = cur.fetchall()
item_names = [r[0] for r in rows if r[0]]
if not item_names:
# Only create if none exist. Use a sensible name from the path if possible.
# We don't have the hydrus_path here easily, but we can try to find it.
# For now, if we are in bootstrap, we might just be setting a global.
# But this function is specifically for store settings.
# Let's use 'home' instead of 'hydrus' as it's the standard default.
item_name = "home"
cur.execute(
"INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) VALUES (?, ?, ?, ?, ?)",
('store', 'hydrusnetwork', item_name, 'NAME', item_name)
)
item_names = [item_name]
# Update all existing instances with this key/value
for name in item_names:
cur.execute(
"INSERT OR REPLACE INTO config (category, subtype, item_name, key, value) VALUES (?, ?, ?, ?, ?)",
('store', 'hydrusnetwork', name, key, value)
)
conn.commit()
return True
except Exception as e:
print(f"Error updating database config: {e}")
# Fallback to config.conf
if not config_path.exists():
fallback = root / "config.conf.remove"
if fallback.exists():
shutil.copy(fallback, config_path)
else:
return False
try:
content = config_path.read_text(encoding="utf-8")
pattern = rf'^(\s*{re.escape(key)}\s*=\s*)(.*)$'
if re.search(pattern, content, flags=re.MULTILINE):
new_content = re.sub(pattern, rf'\1"{value}"', content, flags=re.MULTILINE)
else:
section_pattern = r'\[store=hydrusnetwork\]'
if re.search(section_pattern, content):
new_content = re.sub(section_pattern, f'[store=hydrusnetwork]\n{key}="{value}"', content, count=1)
else:
new_content = content + f'\n\n[store=hydrusnetwork]\nname="hydrus"\n{key}="{value}"'
config_path.write_text(new_content, encoding="utf-8")
return True
except Exception as e:
print(f"Error updating legacy config: {e}")
return False
def _interactive_menu() -> str | int:
"""Show a simple interactive menu to choose install/uninstall or delegate."""
try:
installed = _is_installed()
while True:
os.system("cls" if os.name == "nt" else "clear")
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(f"{logo_padding}{line.rstrip()}")
print("\n")
menu_title = " MEDEIA MACINA BOOTSTRAP MENU "
border = "=" * len(menu_title)
print(border.center(term_width))
print(menu_title.center(term_width))
print(border.center(term_width))
print("\n")
# Define menu options
options = [
"1) Reinstall" if installed else "1) Install",
"2) Extras > HydrusNetwork"
]
if installed:
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 = " " * 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"
if choice in ("2", "extras", "hydrus"):
return "extras_hydrus"
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:
# Non-interactive, fall back to delegating to platform helper
return "delegate"
def _prompt_hydrus_install_location() -> tuple[Path, str] | None:
"""Ask the user for the Hydrus installation root and folder name."""
default_root = Path.home()
default_dest_name = "hydrusnetwork"
print("\n[Standalone Hydrus Installation]")
print("Choose where to install HydrusNetwork (the installer will create the repo there).")
try:
root_input = input(f"Root directory [{default_root}]: ").strip()
if root_input:
if len(root_input) == 2 and root_input[1] == ":" and root_input[0].isalpha():
root_input += "\\"
expanded = os.path.expandvars(os.path.expanduser(root_input))
root_path = Path(expanded).resolve()
else:
root_path = default_root
dest_input = input(f"Folder name [{default_dest_name}]: ").strip()
dest_name = dest_input or default_dest_name
return root_path, dest_name
except (EOFError, KeyboardInterrupt):
print("\nHydrus installation cancelled.")
return None
def _clone_repo(url: str, dest: Path, depth: int = 1) -> bool:
"""Helper to clone a repository."""
try:
cmd = ["git", "clone", url, str(dest)]
if depth:
cmd.extend(["--depth", str(depth)])
subprocess.check_call(cmd)
return True
except Exception as e:
print(f"Error: Failed to clone repository: {e}", file=sys.stderr)
return False
def _download_hydrus_installer(dest: Path) -> bool:
"""Download the hydrusnetwork.py helper script into the provided path."""
last_exc: Exception | None = None
for url in HYDRUS_INSTALLER_SCRIPT_URLS:
try:
# Add a user-agent to avoid being blocked by some servers
req = urllib.request.Request(url, headers={"User-Agent": "Medeia-Macina-Installer"})
with urllib.request.urlopen(req) as response:
dest.write_bytes(response.read())
return True
except Exception as exc:
last_exc = exc
if last_exc:
print(f"Error: Failed to download Hydrus installer script: {last_exc}", file=sys.stderr)
else:
print("Error: Failed to download Hydrus installer script", file=sys.stderr)
return False
def _ensure_repo_available() -> bool:
"""Prompt for a clone location when running outside the repository."""
nonlocal repo_root, script_dir, is_in_repo
# 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 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)
return False
try:
# When piped, script_path is None. We don't want to use the detected repo_root
# because that's just CWD.
if script_path is not None and repo_root is not None:
default_install = repo_root
else:
# When piped, default to home folder on POSIX, CWD on Windows
if platform.system().lower() != "windows":
default_install = Path.home() / "medios"
else:
default_install = Path.cwd() / "Medios-Macina"
print("\n[WEB INSTALLER MODE]")
print(f"Current working directory: {Path.cwd()}")
print("Where would you like to install Medios-Macina?")
# Use sys.stdin.readline() to be more robust than input() in some terminal environments
sys.stdout.write(f"Installation directory [{default_install}]: ")
sys.stdout.flush()
install_dir_raw = sys.stdin.readline().strip()
if not install_dir_raw:
install_path = default_install
else:
# Resolve while expanding user paths (~) and environment variables ($HOME)
expanded = os.path.expandvars(os.path.expanduser(install_dir_raw))
install_path = Path(expanded).resolve()
if install_path is None:
print("Error: Could not determine installation path.", file=sys.stderr)
return False
except (EOFError, KeyboardInterrupt):
return False
if not install_path.exists():
print(f"Creating directory: {install_path}")
install_path.mkdir(parents=True, exist_ok=True)
if _is_valid_mm_repo(install_path):
if not args.quiet:
print(f"Using existing repository in {install_path}.")
repo_root = install_path
else:
print(f"Cloning Medios-Macina into {install_path} (depth 1)...")
print(f"Source: {REPO_URL}")
if _clone_repo(REPO_URL, install_path, depth=1):
repo_root = install_path
else:
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")
script_dir = repo_root / "scripts"
is_in_repo = True
return True
# If the user passed --uninstall explicitly, perform non-interactive uninstall and exit
if args.uninstall:
return _do_uninstall()
if args.check_install:
# Verify mm command is properly installed
home = Path.home()
system = platform.system().lower()
print("Checking 'mm' command installation...")
print()
if system == "windows":
user_bin = Path(os.environ.get("USERPROFILE", str(home))) / "bin"
mm_bat = user_bin / "mm.bat"
print("Checking for shim files:")
print(f" mm.bat: {'' if mm_bat.exists() else ''} ({mm_bat})")
print()
if mm_bat.exists():
bat_content = mm_bat.read_text(encoding="utf-8")
if "REPO=" in bat_content or "ENTRY=" in bat_content:
print(f" mm.bat content looks valid ({len(bat_content)} bytes)")
else:
print(" ⚠️ mm.bat content may be corrupted")
print()
# Check PATH
path = os.environ.get("PATH", "")
user_bin_str = str(user_bin)
in_path = user_bin_str in path
print("Checking PATH environment variable:")
print(f" {user_bin_str} in current session PATH: {'' if in_path else ''}")
# Check registry
try:
import winreg
reg = winreg.ConnectRegistry(None, winreg.HKEY_CURRENT_USER)
key = winreg.OpenKey(reg, "Environment", 0, winreg.KEY_READ)
current_path = winreg.QueryValueEx(key, "Path")[0]
winreg.CloseKey(key)
in_reg = user_bin_str in current_path
print(f" {user_bin_str} in registry PATH: {'' if in_reg else ''}")
if not in_reg:
print()
print("📝 Note: Path is not in registry. It may work in this session but won't persist.")
print(f" To fix, run: [Environment]::SetEnvironmentVariable('PATH', '{user_bin_str};' + [Environment]::GetEnvironmentVariable('PATH','User'), 'User')")
except Exception as e:
print(f" Could not check registry: {e}")
print()
# Test if mm command works
print("Testing 'mm' command...")
try:
result = subprocess.run(["mm", "--help"], capture_output=True, text=True, timeout=5)
if result.returncode == 0:
print("'mm --help' works!")
print(f" Output (first line): {result.stdout.split(chr(10))[0]}")
else:
print(f"'mm --help' failed with exit code {result.returncode}")
if result.stderr:
print(f" Error: {result.stderr.strip()}")
except FileNotFoundError:
# mm not found via PATH, try calling the .ps1 directly
print("'mm' command not found in PATH")
print(" Shims exist but command is not accessible via PATH")
print()
print("Attempting to call shim directly...")
try:
result = subprocess.run(
[str(mm_bat), "--help"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
print(" ✓ Direct shim call works!")
print(" The shim files are valid and functional.")
print()
print("⚠️ 'mm' is not in PATH, but the shims are working correctly.")
print()
print("Possible causes and fixes:")
print(" 1. Terminal needs restart: Close and reopen your terminal/PowerShell")
print(" 2. PATH reload: Run: $env:Path = [Environment]::GetEnvironmentVariable('PATH', 'User') + ';' + [Environment]::GetEnvironmentVariable('PATH', 'Machine')")
print(f" 3. Manual PATH: Add {user_bin} to your system PATH manually")
else:
print(" ✗ Direct shim call failed")
if result.stderr:
print(f" Error: {result.stderr.strip()}")
except Exception as e:
print(f" ✗ Could not test direct shim: {e}")
except subprocess.TimeoutExpired:
print("'mm' command timed out")
except Exception as e:
print(f" ✗ Error testing 'mm': {e}")
else:
# POSIX (Linux/macOS)
# Check likely installation locations
locations = [home / ".local" / "bin" / "mm", Path("/usr/local/bin/mm"), Path("/usr/bin/mm")]
found_shims = [p for p in locations if p.exists()]
print("Checking for shim files:")
for p in locations:
if p.exists():
print(f" mm: ✓ ({p})")
else:
if args.debug:
print(f" mm: ✗ ({p})")
if not found_shims:
print(" mm: ✗ (No shim found in standard locations)")
print()
path = os.environ.get("PATH", "")
# Find which 'mm' is actually being run
actual_mm = shutil.which("mm")
print("Checking PATH environment variable:")
if actual_mm:
print(f" 'mm' resolved to: {actual_mm}")
# Check if it's in a directory on the PATH
if any(str(Path(actual_mm).parent) in p for p in path.split(os.pathsep)):
print(" Command is accessible via current session PATH: ✓")
else:
print(" Command is found but directory may not be in current PATH: ⚠️")
else:
print(" 'mm' not found in current session PATH: ✗")
print()
# Test if mm command works
print("Testing 'mm' command...")
try:
result = subprocess.run(["mm", "--help"], capture_output=True, text=True, timeout=5)
if result.returncode == 0:
print("'mm --help' works!")
print(f" Output (first line): {result.stdout.split(chr(10))[0]}")
else:
print(f"'mm --help' failed with exit code {result.returncode}")
if result.stderr:
print(f" Error: {result.stderr.strip()}")
except FileNotFoundError:
print("'mm' command not found in PATH")
except Exception as e:
print(f" ✗ Error testing 'mm': {e}")
print()
print("✅ Installation check complete!")
return 0
# If no specific action flag is passed and we're in a terminal (or we're being piped), show the menu
if (sys.stdin.isatty() or sys.stdout.isatty() or script_path is None) and not args.quiet:
while True:
sel = _interactive_menu()
if sel == "install":
if not _ensure_repo_available():
return 1
args.skip_deps = False
args.install_editable = True
args.no_playwright = False
# Break the loop to proceed with the main installation steps below
break
elif sel == "extras_hydrus":
install_location = _prompt_hydrus_install_location()
if install_location is None:
continue
install_root, install_dest = install_location
# Choice 2 is for installing HydrusNetwork standalone/independently.
# We preferentially use the local script if already in a repo.
hydrus_script = None
temp_installer_path: Path | None = None
temp_hydrus_repo: Path | None = None
if is_in_repo and repo_root:
hydrus_script = repo_root / "scripts" / "hydrusnetwork.py"
if not hydrus_script or not hydrus_script.exists():
print("Downloading the Hydrus installation helper...")
try:
fd, path = tempfile.mkstemp(prefix="mm_hydrus_", suffix=".py")
os.close(fd)
helper_path = Path(path)
if _download_hydrus_installer(helper_path):
hydrus_script = helper_path
temp_installer_path = helper_path
else:
helper_path.unlink(missing_ok=True)
hydrus_script = None
except Exception as e:
print(f"Error setting up temporary installer: {e}")
hydrus_script = None
if (not hydrus_script or not hydrus_script.exists()) and temp_hydrus_repo is None:
print("Falling back to clone the Medios-Macina repository to obtain the helper script...")
try:
temp_mm_repo_dir = Path(tempfile.mkdtemp(prefix="mm_repo_"))
if _clone_repo(REPO_URL, temp_mm_repo_dir, depth=1):
hydrus_script = temp_mm_repo_dir / "scripts" / "hydrusnetwork.py"
temp_hydrus_repo = temp_mm_repo_dir
else:
shutil.rmtree(temp_mm_repo_dir, ignore_errors=True)
hydrus_script = None
except Exception as e:
print(f"Error cloning Medios-Macina repo: {e}")
hydrus_script = None
if hydrus_script and hydrus_script.exists():
try:
# Clear out project-venv related env vars to prevent auto-reexec
env = os.environ.copy()
env.pop("VIRTUAL_ENV", None)
env.pop("PYTHONHOME", None)
env.pop("PYTHONPATH", None)
# We use sys.executable (the one running bootstrap.py) to run hydrusnetwork.py
# This ensures it uses the same environment that started the bootstrap.
# Pass sys.stdin to ensure the subprocess can talk to the terminal.
subprocess.check_call(
[
sys.executable,
str(hydrus_script),
"--no-project-venv",
"--root",
str(install_root),
"--dest-name",
install_dest,
],
env=env,
stdin=sys.stdin
)
# Update the main project's config with the new Hydrus path
if is_in_repo and repo_root:
_update_config_value(repo_root, "gitclone", str(Path(install_root) / install_dest))
except subprocess.CalledProcessError:
print("\nHydrusNetwork setup exited with an error.")
except Exception as e:
print(f"\nFailed to run HydrusNetwork setup: {e}")
finally:
if temp_installer_path:
temp_installer_path.unlink(missing_ok=True)
if temp_hydrus_repo is not None:
shutil.rmtree(temp_hydrus_repo, ignore_errors=True)
else:
print(f"\nError: {hydrus_script} not found.")
print("\nHydrus installation task finished.")
sys.stdout.write("Press Enter to return to menu...")
sys.stdout.flush()
sys.stdin.readline()
continue
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()
continue
run_client_script = Path(__file__).parent / "run_client.py"
if not run_client_script.exists():
# Fallback to target repo's copy if our local one is missing
candidates = [
target_repo / "run_client.py",
target_repo / "scripts" / "run_client.py",
]
for candidate in candidates:
if candidate.exists():
run_client_script = candidate
break
if run_client_script and 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",
"--service-user", "hydrusnetwork",
"--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 return to menu...")
sys.stdout.flush()
sys.stdin.readline()
continue
elif sel == "uninstall":
return _do_uninstall()
elif sel == "delegate":
rc = run_platform_bootstrap(repo_root)
if rc != 0:
return rc
if not args.quiet:
print("Platform bootstrap completed successfully.")
return 0
elif sel == 0:
return 0
elif sel == "menu":
continue
elif not args.no_delegate and script_path is not None:
# Default non-interactive behavior: delegate to platform script
rc = run_platform_bootstrap(repo_root)
if rc != 0:
return rc
if not args.quiet:
print("Platform bootstrap completed successfully.")
return 0
if sys.version_info < (3, 8):
print("Warning: Python 3.8+ is recommended.", file=sys.stderr)
# UI setup: Logo and Progress Bar
if not args.quiet and not args.debug:
# Clear the terminal before showing logo
os.system('cls' if os.name == 'nt' else 'clear')
term_width = shutil.get_terminal_size((80, 20)).columns
logo_lines = LOGO.strip('\n').splitlines()
max_line_width = 0
for line in logo_lines:
max_line_width = max(max_line_width, len(line.rstrip()))
padding = ' ' * max((term_width - max_line_width) // 2, 0)
print("\n" * 2)
for line in logo_lines:
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
if not args.no_playwright: total_steps += 1 # Playwright is combined pkg+browsers
if not getattr(args, "no_mpv", False): total_steps += 1
if not getattr(args, "no_deno", False): total_steps += 1
pb = ProgressBar(total_steps, quiet=args.quiet or args.debug)
def _run_cmd(cmd: list[str], cwd: Optional[Path] = None):
"""Helper to run commands with shared settings."""
run(cmd, quiet=not args.debug, debug=args.debug, cwd=cwd)
# Opinionated: always create or use a local venv at the project root (.venv)
venv_dir = repo_root / ".venv"
# Validate that venv_dir is where we expect it to be
if not args.quiet:
print(f"Planned venv location: {venv_dir}")
if venv_dir.parent != repo_root:
print(f"WARNING: venv parent is {venv_dir.parent}, expected {repo_root}", file=sys.stderr)
if "scripts" in str(venv_dir).lower():
print(f"WARNING: venv path contains 'scripts': {venv_dir}", file=sys.stderr)
def _venv_python_bin(p: Path) -> Path:
if platform.system().lower() == "windows":
return p / "Scripts" / "python.exe"
return p / "bin" / "python"
def _ensure_local_venv() -> Path:
"""Create (if missing) and return the path to the venv's python executable."""
try:
if not venv_dir.exists():
_run_cmd([sys.executable, "-m", "venv", str(venv_dir)])
py = _venv_python_bin(venv_dir)
if not py.exists():
_run_cmd([sys.executable, "-m", "venv", str(venv_dir)])
py = _venv_python_bin(venv_dir)
if not py.exists():
raise RuntimeError(f"Unable to locate venv python at {py}")
return py
except subprocess.CalledProcessError as exc:
print(f"Failed to create or prepare local venv: {exc}", file=sys.stderr)
raise
def _ensure_pip_available(python_path: Path) -> None:
"""Ensure pip is available inside the venv; fall back to ensurepip if needed."""
try:
# 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:
# 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,
)
raise
# 1. Virtual Environment Setup
pb.update("Preparing virtual environment...")
venv_python = _ensure_local_venv()
# 2. Pip Availability
pb.update("Checking for pip...")
_ensure_pip_available(venv_python)
# Enforce opinionated behavior: install deps, playwright, deno, and install project in editable mode.
# Ignore `--skip-deps` and `--install-editable` flags to keep the setup deterministic.
args.skip_deps = False
args.install_editable = True
args.no_playwright = False
try:
if args.playwright_only:
# Playwright browser install (short-circuit)
if not playwright_package_installed():
_run_cmd([sys.executable, "-m", "pip", "install", "--no-cache-dir", "playwright"])
try:
cmd = _build_playwright_install_cmd(args.browsers)
cmd[0] = str(venv_python)
_run_cmd(cmd)
except Exception as exc:
print(f"Error: {exc}", file=sys.stderr)
return 2
return 0
# Progress tracking continues for full install
if args.upgrade_pip:
pb.update("Upgrading pip/setuptools/wheel...")
_run_cmd(
[
str(venv_python),
"-m",
"pip",
"install",
"--upgrade",
"--no-cache-dir",
"pip",
"setuptools",
"wheel",
]
)
# 4. Core Dependencies
pb.update("Installing core dependencies...")
req_file = repo_root / "scripts" / "requirements.txt"
if req_file.exists():
_run_cmd([str(venv_python), "-m", "pip", "install", "--no-cache-dir", "-r", str(req_file)])
# 5. Playwright Setup
if not args.no_playwright:
pb.update("Setting up Playwright and browsers...")
if not playwright_package_installed():
_run_cmd([str(venv_python), "-m", "pip", "install", "--no-cache-dir", "playwright"])
try:
cmd = _build_playwright_install_cmd(args.browsers)
cmd[0] = str(venv_python)
_run_cmd(cmd)
except Exception:
pass
# 6. Internal Components
pb.update("Installing internal components...")
if platform.system() != "Windows":
old_mm = venv_dir / "bin" / "mm"
if old_mm.exists():
try:
old_mm.unlink()
except Exception:
pass
_run_cmd([str(venv_python), "-m", "pip", "install", "--no-cache-dir", "-e", str(repo_root / "scripts")])
# 7. CLI Verification
pb.update("Verifying CLI configuration...")
# Check core imports (skip optional python-mpv here because it depends on
# the system libmpv shared library and is not required for the repo-local
# MPV IPC integration; MPV executable availability is handled below.)
try:
missing = []
for mod in ["importlib", "shutil", "subprocess"]:
try:
# Use run() for verification to ensure clean environment
run([str(venv_python), "-c", f"import {mod}"], quiet=True)
except Exception:
missing.append(mod)
# Note: If the MPV executable is missing it will be handled by the
# MPV installation step later in this script; do not attempt to
# install the third-party `python-mpv` package here.
if missing:
print(f"\nWarning: The following packages were not importable in the venv: {', '.join(missing)}")
except Exception:
pass
try:
# 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",
(
"import site, sysconfig\n"
"out=[]\n"
"try:\n out.extend(site.getsitepackages())\nexcept Exception:\n pass\n"
"try:\n p = sysconfig.get_paths().get('purelib')\n if p:\n out.append(p)\nexcept Exception:\n pass\n"
"seen=[]; res=[]\n"
"for x in out:\n if x and x not in seen:\n seen.append(x); res.append(x)\n"
"for s in res:\n print(s)\n"
),
]
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():
site_dir = Path(sp)
break
if site_dir:
pth_file = site_dir / "medeia_repo.pth"
content = str(repo_root) + "\n"
if pth_file.exists():
txt = pth_file.read_text(encoding="utf-8")
if str(repo_root) not in txt:
with pth_file.open("a", encoding="utf-8") as fh:
fh.write(content)
else:
with pth_file.open("w", encoding="utf-8") as fh:
fh.write(content)
except Exception:
pass
except Exception:
pass
# 8. MPV
install_mpv_requested = not getattr(args, "no_mpv", False)
if install_mpv_requested:
pb.update("Setting up MPV media player...")
if not _check_mpv_installed():
_install_mpv()
# 9. Deno
install_deno_requested = not getattr(args, "no_deno", False)
if install_deno_requested:
pb.update("Setting up Deno runtime...")
if not _check_deno_installed():
_install_deno(args.deno_version)
# 10. Finalizing setup
pb.update("Writing launcher scripts...")
def _write_launchers() -> None:
launcher_dir = repo_root / "scripts"
launcher_dir.mkdir(parents=True, exist_ok=True)
ps1 = launcher_dir / "mm.ps1"
ps1_text = r"""Param([Parameter(ValueFromRemainingArguments=$true)] $args)
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repo = (Resolve-Path (Join-Path $scriptDir "..")).Path
# Automatically check for updates if this is a git repository
if (Test-Path (Join-Path $repo ".git")) {
try {
if (-not $env:MM_NO_UPDATE) {
$conf = Join-Path $repo "config.conf"
$skip = $false
if (Test-Path $conf) {
if ((Get-Content $conf | Select-String "auto_update\s*=\s*(false|no|off|0)") -ne $null) {
$skip = $true
}
}
if (-not $skip) {
Write-Host "Checking for updates..." -ForegroundColor Gray
$update = git -C "$repo" pull --ff-only 2>&1
if ($update -like "*Updating*" -or $update -like "*Fast-forward*") {
Clear-Host
Write-Host "Medeia-Macina has been updated. Please restart the application to apply changes." -ForegroundColor Cyan
exit 0
}
Clear-Host
}
}
} catch {}
}
$venv = Join-Path $repo '.venv'
$py = Join-Path $venv 'Scripts\python.exe'
if (Test-Path $py) {
& $py -m scripts.cli_entry @args; exit $LASTEXITCODE
}
# Ensure venv Scripts dir is on PATH for provider discovery
$venvScripts = Join-Path $venv 'Scripts'
if (Test-Path $venvScripts) { $env:PATH = $venvScripts + ';' + $env:PATH }
# Fallback to system python if venv doesn't exist
if (Test-Path (Join-Path $repo 'CLI.py')) {
python -m scripts.cli_entry @args
} else {
python -m scripts.cli_entry @args
}
"""
try:
ps1.write_text(ps1_text, encoding="utf-8")
except Exception:
pass
_write_launchers()
# 11. Global Environment
pb.update("Configuring global environment...")
def _install_user_shims(repo: Path) -> None:
try:
home = Path.home()
system = platform.system().lower()
if system == "windows":
user_bin = Path(os.environ.get("USERPROFILE", str(home))) / "bin"
user_bin.mkdir(parents=True, exist_ok=True)
# Validate repo path
if not (repo / ".venv").exists():
print(f"WARNING: venv not found at {repo}/.venv - mm command may not work", file=sys.stderr)
if not (repo / "scripts").exists():
print(f"WARNING: scripts folder not found at {repo}/scripts - mm command may not work", file=sys.stderr)
# Write mm.bat (batch shim works in all shells, bypasses PowerShell execution policy)
mm_bat = user_bin / "mm.bat"
repo_bat_str = str(repo)
bat_text = (
"@echo off\n"
"setlocal enabledelayedexpansion\n"
f'set "REPO={repo_bat_str}"\n'
"\n"
":: Automatically check for updates if this is a git repository\n"
"if not defined MM_NO_UPDATE (\n"
" if exist \"!REPO!\\.git\" (\n"
" set \"AUTO_UPDATE=true\"\n"
" if exist \"!REPO!\\medios.db\" (\n"
" \"sqlite3\" \"!REPO!\\medios.db\" \"SELECT value FROM config WHERE key='auto_update' AND category='global'\" | findstr /i /r \"false no off 0\" >nul 2>&1\n"
" if !errorlevel! == 0 set \"AUTO_UPDATE=false\"\n"
" ) else if exist \"!REPO!\\config.conf\" (\n"
" findstr /i /r \"auto_update.*=.*false auto_update.*=.*no auto_update.*=.*off auto_update.*=.*0\" \"!REPO!\\config.conf\" >nul 2>&1\n"
" if !errorlevel! == 0 set \"AUTO_UPDATE=false\"\n"
" )\n"
" if \"!AUTO_UPDATE!\" == \"true\" (\n"
" echo Checking for updates...\n"
" git -C \"!REPO!\" pull --ff-only | findstr /i /c:\"Updating\" /c:\"Fast-forward\" >nul 2>&1\n"
" if !errorlevel! == 0 (\n"
" cls\n"
" echo Medeia-Macina has been updated. Please restart the application to apply changes.\n"
" exit /b 0\n"
" )\n"
" cls\n"
" )\n"
" )\n"
")\n"
"\n"
"set \"VENV=!REPO!\\.venv\"\n"
"set \"PY=!VENV!\\Scripts\\python.exe\"\n"
"set \"ENTRY=!REPO!\\scripts\\cli_entry.py\"\n"
"if exist \"!PY!\" (\n"
" \"!PY!\" \"!ENTRY!\" %*\n"
" exit /b !ERRORLEVEL!\n"
")\n"
"python \"!ENTRY!\" %*\n"
"exit /b !ERRORLEVEL!\n"
)
if mm_bat.exists():
bak = mm_bat.with_suffix(f".bak{int(time.time())}")
mm_bat.replace(bak)
mm_bat.write_text(bat_text, encoding="utf-8")
# Validate that the batch shim was created correctly
bat_ok = mm_bat.exists() and len(mm_bat.read_text(encoding="utf-8")) > 0
if not bat_ok:
raise RuntimeError("Failed to create mm.bat shim")
if args.debug:
print(f"DEBUG: Created mm.bat ({len(bat_text)} bytes)")
print(f"DEBUG: Repo path embedded in shim: {repo}")
print(f"DEBUG: Venv location: {repo}/.venv")
print(f"DEBUG: Shim directory: {user_bin}")
# Add user_bin to PATH for current and future sessions
str_bin = str(user_bin)
cur_path = os.environ.get("PATH", "")
# Update current session PATH if not already present
if str_bin not in cur_path:
os.environ["PATH"] = str_bin + ";" + cur_path
# Persist to user's Windows registry PATH for future sessions
try:
ps_cmd = (
"$bin = '{bin}';"
"$cur = [Environment]::GetEnvironmentVariable('PATH','User');"
"if ($cur -notlike \"*$bin*\") {{"
" $val = if ($cur) {{ $bin + ';' + $cur }} else {{ $bin }};"
" [Environment]::SetEnvironmentVariable('PATH', $val, 'User');"
"}}"
).format(bin=str_bin.replace("\\", "\\\\"))
result = subprocess.run(
["powershell",
"-NoProfile",
"-Command",
ps_cmd],
check=False,
capture_output=True,
text=True
)
if args.debug and result.stderr:
print(f"DEBUG: PowerShell output: {result.stderr}")
# Also reload PATH in current session for immediate availability
reload_cmd = (
"$env:PATH = [Environment]::GetEnvironmentVariable('PATH', 'User') + ';' + [Environment]::GetEnvironmentVariable('PATH', 'Machine')"
)
subprocess.run(
["powershell",
"-NoProfile",
"-Command",
reload_cmd],
check=False,
capture_output=True,
text=True
)
except Exception as e:
if args.debug:
print(f"DEBUG: Could not persist PATH to registry: {e}", file=sys.stderr)
if not args.quiet:
print(f"Installed global launcher to: {user_bin}")
print("✓ mm.bat (Command Prompt and PowerShell)")
print()
print("You can now run 'mm' from any terminal window.")
print("If 'mm' is not found, restart your terminal or reload PATH:")
print(" PowerShell: $env:PATH = [Environment]::GetEnvironmentVariable('PATH','User') + ';' + [Environment]::GetEnvironmentVariable('PATH','Machine')")
print(" CMD: path %PATH%")
else:
# POSIX
# If running as root (id 0), prefer /usr/bin or /usr/local/bin which are standard on PATH
is_root = False
try:
if platform.system().lower() != "windows" and os.getuid() == 0:
is_root = True
except (AttributeError, Exception):
pass
if is_root:
user_bin = Path("/usr/local/bin")
if not os.access(user_bin, os.W_OK):
user_bin = Path("/usr/bin")
else:
user_bin = Path(os.environ.get("XDG_BIN_HOME", str(home / ".local/bin")))
user_bin.mkdir(parents=True, exist_ok=True)
mm_sh = user_bin / "mm"
# Search PATH/standard locations for existing 'mm' shims to avoid conflicts
common_paths = [user_bin, Path("/usr/local/bin"), Path("/usr/bin"), home / ".local" / "bin"]
for p_dir in common_paths:
p_mm = p_dir / "mm"
if p_mm.exists() and p_mm.resolve() != mm_sh.resolve():
try:
# Only remove if it looks like one of our shims
content = p_mm.read_text(encoding="utf-8", errors="ignore")
if "Medeia" in content or "Medios" in content or "cli_entry" in content:
p_mm.unlink()
if not args.quiet:
print(f"Removed conflicting old shim: {p_mm}")
except Exception:
pass
# Remove old launcher to overwrite with new one
if mm_sh.exists():
try:
mm_sh.unlink()
except Exception:
pass
sh_text = (
"#!/usr/bin/env bash\n"
"set -e\n"
f'REPO="{repo}"\n'
"# Prefer git top-level when available to avoid embedding a parent path.\n"
"if command -v git >/dev/null 2>&1; then\n"
' gitroot=$(git -C "$REPO" rev-parse --show-toplevel 2>/dev/null || true)\n'
' if [ -n "$gitroot" ]; then\n'
' REPO="$gitroot"\n'
" fi\n"
"fi\n"
"# If git not available or didn't resolve, walk up from CWD to find a project root.\n"
'if [ ! -f "$REPO/CLI.py" ] && [ ! -f "$REPO/pyproject.toml" ] && [ ! -f "$REPO/scripts/pyproject.toml" ]; then\n'
' CUR="$(pwd -P)"\n'
' while [ "$CUR" != "/" ] && [ "$CUR" != "" ]; do\n'
' if [ -f "$CUR/CLI.py" ] || [ -f "$CUR/pyproject.toml" ] || [ -f "$CUR/scripts/pyproject.toml" ]; then\n'
' REPO="$CUR"\n'
" break\n"
" fi\n"
' CUR="$(dirname "$CUR")"\n'
" done\n"
"fi\n"
'VENV="$REPO/.venv"\n'
"# Debug mode: set MM_DEBUG=1 to print repository, venv, and import diagnostics\n"
'if [ -n "${MM_DEBUG:-}" ]; then\n'
' echo "MM_DEBUG: diagnostics" >&2\n'
' echo "Resolved REPO: $REPO" >&2\n'
' echo "Resolved VENV: $VENV" >&2\n'
' echo "VENV exists: $( [ -d "$VENV" ] && echo yes || echo no )" >&2\n'
' echo "Candidates:" >&2\n'
' echo " VENV/bin/mm: $( [ -x "$VENV/bin/mm" ] && echo yes || echo no )" >&2\n'
' echo " VENV/bin/python3: $( [ -x "$VENV/bin/python3" ] && echo yes || echo no )" >&2\n'
' echo " VENV/bin/python: $( [ -x "$VENV/bin/python" ] && echo yes || echo no )" >&2\n'
' echo " system python3: $(command -v python3 || echo none)" >&2\n'
' echo " system python: $(command -v python || echo none)" >&2\n'
' for pycmd in "$VENV/bin/python3" "$VENV/bin/python" "$(command -v python3 2>/dev/null)" "$(command -v python 2>/dev/null)"; do\n'
' if [ -n "$pycmd" ] && [ -x "$pycmd" ]; then\n'
' echo "---- Testing with: $pycmd ----" >&2\n'
" $pycmd - <<'PY'\nimport sys, importlib, traceback, importlib.util\nprint('sys.executable:', sys.executable)\nprint('sys.path (first 8):', sys.path[:8])\nfor mod in ('CLI','medeia_macina','scripts.cli_entry'):\n try:\n spec = importlib.util.find_spec(mod)\n print(mod, 'spec:', spec)\n if spec:\n m = importlib.import_module(mod)\n print(mod, 'loaded at', getattr(m, '__file__', None))\n except Exception:\n print(mod, 'import failed')\n traceback.print_exc()\nPY\n"
" fi\n"
" done\n"
' echo "MM_DEBUG: end diagnostics" >&2\n'
"fi\n"
"\n"
"# Automatically check for updates if this is a git repository\n"
'if [ -z "${MM_NO_UPDATE:-}" ] && [ -d "$REPO/.git" ] && command -v git >/dev/null 2>&1; then\n'
' AUTO_UPDATE="true"\n'
' if [ -f "$REPO/config.conf" ]; then\n'
" if grep -qiE 'auto_update[[:space:]]*=[[:space:]]*(false|no|off|0)' \"$REPO/config.conf\"; then\n"
' AUTO_UPDATE="false"\n'
' fi\n'
' fi\n'
' if [ "$AUTO_UPDATE" = "true" ]; then\n'
' echo "Checking for updates..."\n'
' UPDATE_OUT=$(git -C "$REPO" pull --ff-only 2>&1)\n'
' if echo "$UPDATE_OUT" | grep -qiE \'Updating|Fast-forward\'; then\n'
' clear\n'
' echo "Medeia-Macina has been updated. Please restart the application to apply changes."\n'
' exit 0\n'
' fi\n'
' clear\n'
' fi\n'
"fi\n"
"\n"
"# Use -m scripts.cli_entry directly instead of pip-generated wrapper to avoid entry point issues\n"
"# Prefer venv's python3, then venv's python\n"
'if [ -x "$VENV/bin/python3" ]; then\n'
' exec "$VENV/bin/python3" -m scripts.cli_entry "$@"\n'
"fi\n"
'if [ -x "$VENV/bin/python" ]; then\n'
' exec "$VENV/bin/python" -m scripts.cli_entry "$@"\n'
"fi\n"
"# Fallback to system python3, then system python (only if it's Python 3)\n"
"if command -v python3 >/dev/null 2>&1; then\n"
' exec python3 -m scripts.cli_entry "$@"\n'
"fi\n"
"if command -v python >/dev/null 2>&1; then\n"
" if python -c 'import sys; sys.exit(0 if sys.version_info[0] >= 3 else 1)'; then\n"
' exec python -m scripts.cli_entry "$@"\n'
" fi\n"
"fi\n"
"echo 'Error: no suitable Python 3 interpreter found. Please install Python 3 or use the venv.' >&2\n"
"exit 127\n"
)
if mm_sh.exists():
bak = mm_sh.with_suffix(f".bak{int(time.time())}")
mm_sh.replace(bak)
mm_sh.write_text(sh_text, encoding="utf-8")
mm_sh.chmod(mm_sh.stat().st_mode | 0o111)
# Ensure the user's bin is on PATH for future sessions by adding to ~/.profile
cur_path = os.environ.get("PATH", "")
if str(user_bin) not in cur_path:
profile = home / ".profile"
snippet = (
"# Added by Medeia-Macina setup: ensure user local bin is on PATH\n"
'if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then\n'
' PATH="$HOME/.local/bin:$PATH"\n'
"fi\n"
)
try:
txt = profile.read_text() if profile.exists() else ""
if snippet.strip() not in txt:
with profile.open("a", encoding="utf-8") as fh:
fh.write("\n" + snippet)
except Exception:
pass
if not args.quiet:
print(f"Installed global launcher to: {mm_sh}")
except Exception as exc: # pragma: no cover - best effort
print(f"Failed to install global shims: {exc}", file=sys.stderr)
_install_user_shims(repo_root)
if not args.quiet:
os.system('cls' if os.name == 'nt' else 'clear')
print()
print("launch command from terminal: mm")
print("inside the app: .config")
print()
return 0
except subprocess.CalledProcessError as exc:
print(
f"Error: command failed with exit {exc.returncode}: {exc}",
file=sys.stderr
)
return int(exc.returncode or 1)
except Exception as exc: # pragma: no cover - defensive
print(f"Unexpected error: {exc}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())