Files
Medios-Macina/scripts/bootstrap.py
2026-01-11 12:28:16 -08:00

1505 lines
66 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 _update_config_value(root: Path, key: str, value: str) -> bool:
config_path = root / "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 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")
print("====================================")
print(" MEDEIA MACINA BOOTSTRAP MENU")
print("====================================")
print("1) Install / Reinstall")
print("2) Extras > HydrusNetwork")
if installed:
print("3) Uninstall")
print("4) Status")
print("q) Quit")
choice = input("\nChoose an option: ").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 installed and choice in ("4", "status"):
print("\nInstallation detected." if installed else "\nNot installed.")
input("\nPress Enter to continue...")
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 no specific action flag is passed and we're in a terminal, show the menu
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)])
# New: Prompt for location as requested
print("\n" + "="*40)
print(" HYDRUS CONFIGURATION")
print("="*40)
location = input("\nEnter the absolute path to your Hydrus git clone\n(to link it with Medios-Macina config): ").strip()
if location:
if _update_config_value(repo_root, "gitclone", location):
print(f"✅ Updated config.conf with gitclone=\"{location}\"")
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
elif sel == 0:
return 0
elif not args.no_delegate:
# 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)
# 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
$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()
# 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 | 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(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[[: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:
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())