1452 lines
63 KiB
Python
1452 lines
63 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
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
|
|
def run(cmd: list[str]) -> None:
|
|
print(f"> {' '.join(cmd)}")
|
|
subprocess.check_call(cmd)
|
|
|
|
|
|
# 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:
|
|
import playwright # type: ignore
|
|
|
|
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:
|
|
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
|
|
script_dir = Path(__file__).resolve().parent
|
|
repo_root = script_dir.parent
|
|
|
|
if not args.quiet:
|
|
print(f"Bootstrap script location: {script_dir}")
|
|
print(f"Detected project root: {repo_root}")
|
|
print(f"Current working directory: {Path.cwd()}")
|
|
|
|
# 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."""
|
|
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
|
|
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.
|
|
try:
|
|
current_exe = Path(sys.executable).resolve()
|
|
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 _interactive_menu() -> str | int:
|
|
"""Show a simple interactive menu to choose install/uninstall or delegate."""
|
|
try:
|
|
installed = _is_installed()
|
|
while True:
|
|
print("\nMedeia-Macina bootstrap - interactive menu")
|
|
print("1) Install / Reinstall")
|
|
print("2) Extras")
|
|
if installed:
|
|
print("3) Uninstall")
|
|
print("4) Status")
|
|
print("q) Quit")
|
|
|
|
choice = input("Choose an option: ").strip().lower()
|
|
|
|
if choice in ("1", "install", "reinstall"):
|
|
return "install"
|
|
|
|
if choice in ("2", "extras"):
|
|
print("\nExtras Menu:")
|
|
print(" 1) HydrusNetwork (Setup & Clone)")
|
|
print(" b) Back")
|
|
extra_choice = input("Choose an extra: ").strip().lower()
|
|
if extra_choice == "1":
|
|
return "extras_hydrus"
|
|
continue # back to main menu
|
|
|
|
if installed and choice in ("3", "uninstall"):
|
|
return "uninstall"
|
|
|
|
if installed and choice in ("4", "status"):
|
|
print("Installation detected." if installed else "Not installed.")
|
|
continue
|
|
|
|
if choice in ("q", "quit", "exit"):
|
|
return 0
|
|
except EOFError:
|
|
# Non-interactive, fall back to delegating to platform helper
|
|
return "delegate"
|
|
|
|
# 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(f"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(f" ⚠️ 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(f"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(f" ✓ '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(f" ✗ 'mm' command not found in PATH")
|
|
print(f" 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(f" ✓ Direct shim call works!")
|
|
print(f" 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(f" 1. Terminal needs restart: Close and reopen your terminal/PowerShell")
|
|
print(f" 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(f" ✗ 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(f" ✗ '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(f"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(f" 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(f"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(f" Command is accessible via current session PATH: ✓")
|
|
else:
|
|
print(f" Command is found but directory may not be in current PATH: ⚠️")
|
|
else:
|
|
print(f" '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(f" ✓ '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(f" ✗ 'mm' command not found in PATH")
|
|
except Exception as e:
|
|
print(f" ✗ Error testing 'mm': {e}")
|
|
|
|
print()
|
|
print("✅ Installation check complete!")
|
|
return 0
|
|
if sys.stdin.isatty() and not args.quiet:
|
|
sel = _interactive_menu()
|
|
if sel == "install":
|
|
# user chose to install/reinstall; set defaults and continue
|
|
args.skip_deps = False
|
|
args.install_editable = True
|
|
args.no_playwright = False
|
|
elif sel == "extras_hydrus":
|
|
# Special case: run the hydrusnetwork.py script and then exit
|
|
hydrus_script = repo_root / "scripts" / "hydrusnetwork.py"
|
|
if hydrus_script.exists():
|
|
try:
|
|
subprocess.check_call([sys.executable, str(hydrus_script)])
|
|
except subprocess.CalledProcessError:
|
|
print("\nHydrusNetwork setup exited with an error.")
|
|
except Exception as e:
|
|
print(f"\nFailed to run HydrusNetwork setup: {e}")
|
|
else:
|
|
print(f"\nError: {hydrus_script} not found.")
|
|
return 0
|
|
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
|
|
else:
|
|
return int(sel or 0)
|
|
else:
|
|
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)
|
|
|
|
# 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(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():
|
|
if not args.quiet:
|
|
print(f"Creating local virtualenv at: {venv_dir}")
|
|
run([sys.executable, "-m", "venv", str(venv_dir)])
|
|
else:
|
|
if not args.quiet:
|
|
print(f"Using existing virtualenv at: {venv_dir}")
|
|
|
|
py = _venv_python(venv_dir)
|
|
if not py.exists():
|
|
# Try recreating venv if python is missing
|
|
if not args.quiet:
|
|
print(f"Local venv python not found at {py}; recreating venv")
|
|
run([sys.executable, "-m", "venv", str(venv_dir)])
|
|
py = _venv_python(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:
|
|
subprocess.run(
|
|
[str(python_path), "-m", "pip", "--version"],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
check=True,
|
|
)
|
|
return
|
|
except Exception:
|
|
pass
|
|
|
|
if not args.quiet:
|
|
print("Bootstrapping pip inside the local virtualenv...")
|
|
try:
|
|
run([str(python_path), "-m", "ensurepip", "--upgrade"])
|
|
except subprocess.CalledProcessError as exc:
|
|
print(
|
|
"Failed to install pip inside the local virtualenv via ensurepip; ensure your Python build includes ensurepip and retry.",
|
|
file=sys.stderr,
|
|
)
|
|
raise
|
|
|
|
# Ensure a local venv is present and use it for subsequent installs.
|
|
venv_python = _ensure_local_venv()
|
|
if not args.quiet:
|
|
print(f"Using venv python: {venv_python}")
|
|
_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:
|
|
if not playwright_package_installed():
|
|
if not args.quiet:
|
|
print("'playwright' package not found; installing it via pip...")
|
|
run([sys.executable, "-m", "pip", "install", "--no-cache-dir", "playwright"])
|
|
|
|
if not args.quiet:
|
|
print(
|
|
"Installing Playwright browsers (this may download several hundred MB)..."
|
|
)
|
|
try:
|
|
cmd = _build_playwright_install_cmd(args.browsers)
|
|
except ValueError as exc:
|
|
print(f"Error: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
run(cmd)
|
|
if not args.quiet:
|
|
print("Playwright browsers installed successfully.")
|
|
return 0
|
|
|
|
if args.upgrade_pip:
|
|
if not args.quiet:
|
|
print("Upgrading pip, setuptools, and wheel in local venv...")
|
|
run(
|
|
[
|
|
str(venv_python),
|
|
"-m",
|
|
"pip",
|
|
"install",
|
|
"--upgrade",
|
|
"--no-cache-dir",
|
|
"pip",
|
|
"setuptools",
|
|
"wheel",
|
|
]
|
|
)
|
|
|
|
if not args.skip_deps:
|
|
req_file = repo_root / "scripts" / "requirements.txt"
|
|
if not req_file.exists():
|
|
print(
|
|
f"requirements.txt not found at {req_file}; skipping dependency installation.",
|
|
file=sys.stderr,
|
|
)
|
|
else:
|
|
if not args.quiet:
|
|
print(
|
|
f"Installing Python dependencies into local venv from {req_file}..."
|
|
)
|
|
run([str(venv_python), "-m", "pip", "install", "--no-cache-dir", "-r", str(req_file)])
|
|
|
|
if not args.no_playwright:
|
|
if not playwright_package_installed():
|
|
if not args.quiet:
|
|
print("'playwright' package not installed in venv; installing it...")
|
|
run([str(venv_python), "-m", "pip", "install", "--no-cache-dir", "playwright"])
|
|
|
|
if not args.quiet:
|
|
print(
|
|
"Installing Playwright browsers (this may download several hundred MB)..."
|
|
)
|
|
try:
|
|
cmd = _build_playwright_install_cmd(args.browsers)
|
|
except ValueError as exc:
|
|
print(f"Error: {exc}", file=sys.stderr)
|
|
return 2
|
|
|
|
# Run Playwright install using the venv's python so binaries are available in venv
|
|
cmd[0] = str(venv_python)
|
|
run(cmd)
|
|
|
|
# Install the project into the local venv (editable mode is the default, opinionated)
|
|
if not args.quiet:
|
|
print("Installing project into local venv (editable mode)")
|
|
|
|
# Clean up old pip-generated entry point wrapper to avoid stale references
|
|
if platform.system() != "Windows":
|
|
old_mm = venv_dir / "bin" / "mm"
|
|
if old_mm.exists():
|
|
try:
|
|
old_mm.unlink()
|
|
if not args.quiet:
|
|
print(f"Removed old entry point wrapper: {old_mm}")
|
|
except Exception:
|
|
pass
|
|
|
|
run([str(venv_python), "-m", "pip", "install", "--no-cache-dir", "-e", str(repo_root / "scripts")])
|
|
|
|
# Verify top-level 'CLI' import and, if missing, attempt to make it available
|
|
if not args.quiet:
|
|
print("Verifying top-level 'CLI' import in venv...")
|
|
try:
|
|
rc = subprocess.run(
|
|
[
|
|
str(venv_python),
|
|
"-c",
|
|
"import importlib; importlib.import_module('CLI')"
|
|
],
|
|
check=False,
|
|
)
|
|
if rc.returncode == 0:
|
|
print("OK: top-level 'CLI' is importable in the venv.")
|
|
else:
|
|
print(
|
|
"Top-level 'CLI' not importable; attempting to add repo path to venv site-packages via a .pth file..."
|
|
)
|
|
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).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 is None:
|
|
print(
|
|
"Could not determine venv site-packages directory; skipping .pth fallback"
|
|
)
|
|
else:
|
|
pth_file = site_dir / "medeia_repo.pth"
|
|
if pth_file.exists():
|
|
txt = pth_file.read_text(encoding="utf-8")
|
|
if str(repo_root) in txt:
|
|
print(f".pth already contains repo root: {pth_file}")
|
|
else:
|
|
with pth_file.open("a", encoding="utf-8") as fh:
|
|
fh.write(str(repo_root) + "\n")
|
|
print(f"Appended repo root to existing .pth: {pth_file}")
|
|
else:
|
|
with pth_file.open("w", encoding="utf-8") as fh:
|
|
fh.write(str(repo_root) + "\n")
|
|
print(
|
|
f"Wrote .pth adding repo root to venv site-packages: {pth_file}"
|
|
)
|
|
|
|
# Re-check whether CLI can be imported now
|
|
rc2 = subprocess.run(
|
|
[
|
|
str(venv_python),
|
|
"-c",
|
|
"import importlib; importlib.import_module('CLI')",
|
|
],
|
|
check=False,
|
|
)
|
|
if rc2.returncode == 0:
|
|
print("Top-level 'CLI' import works after adding .pth")
|
|
else:
|
|
print(
|
|
"Adding .pth did not make top-level 'CLI' importable; consider creating an egg-link or checking the venv."
|
|
)
|
|
except Exception as exc:
|
|
print(
|
|
f"Warning: failed to verify or modify site-packages for top-level CLI: {exc}"
|
|
)
|
|
|
|
# Check and install MPV if needed
|
|
install_mpv_requested = True
|
|
if getattr(args, "no_mpv", False):
|
|
install_mpv_requested = False
|
|
elif getattr(args, "install_mpv", False):
|
|
install_mpv_requested = True
|
|
|
|
if install_mpv_requested:
|
|
if _check_mpv_installed():
|
|
if not args.quiet:
|
|
print("MPV is already installed.")
|
|
else:
|
|
if not args.quiet:
|
|
print("MPV not found in PATH. Attempting to install...")
|
|
rc = _install_mpv()
|
|
if rc != 0:
|
|
print("Warning: MPV installation failed. Install it manually from https://mpv.io/installation/", file=sys.stderr)
|
|
|
|
# Optional: install Deno runtime (default: install unless --no-deno is passed)
|
|
install_deno_requested = True
|
|
if getattr(args, "no_deno", False):
|
|
install_deno_requested = False
|
|
elif getattr(args, "install_deno", False):
|
|
install_deno_requested = True
|
|
|
|
if install_deno_requested:
|
|
if _check_deno_installed():
|
|
if not args.quiet:
|
|
print("Deno is already installed.")
|
|
else:
|
|
if not args.quiet:
|
|
print("Installing Deno runtime (local/system)...")
|
|
rc = _install_deno(args.deno_version)
|
|
if rc != 0:
|
|
print("Warning: Deno installation failed.", file=sys.stderr)
|
|
|
|
# Write project-local launcher script under scripts/ to keep the repo root uncluttered.
|
|
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
|
|
git -C "$repo" pull --ff-only --quiet
|
|
}
|
|
}
|
|
} 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()
|
|
|
|
# Install user-global shims so `mm` can be executed from any shell session.
|
|
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!\\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 --quiet\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(f"✓ mm.bat (Command Prompt and PowerShell)")
|
|
print()
|
|
print("You can now run 'mm' from any terminal window.")
|
|
print(f"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
|
|
if os.getuid() == 0:
|
|
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\s*=\s*(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'
|
|
' git -C "$REPO" pull --ff-only --quiet || true\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:
|
|
print("\n✅ Setup complete!")
|
|
print()
|
|
print("The 'mm' command should now work from any terminal.")
|
|
print()
|
|
print("Verify the installation:")
|
|
print(" python scripts/bootstrap.py --check-install")
|
|
print()
|
|
print("Then run the app:")
|
|
print(" mm --help")
|
|
print()
|
|
print("💡 If 'mm' is not recognized, close and reopen your terminal.")
|
|
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())
|