#!/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-deno Install the Deno runtime using the official installer --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 _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 ] - Windows: powershell iwr https://deno.land/x/install/install.ps1 -useb | iex; Install-Deno [-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", ) 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") if installed: print("1) Install / Reinstall") print("2) Uninstall") print("3) Status") print("q) Quit") choice = input("Choose an option: ").strip().lower() if not choice or choice in ("1", "install", "reinstall"): return "install" if choice in ("2", "uninstall"): return "uninstall" if choice in ("3", "status"): print("Installation detected." if installed else "Not installed.") continue if choice in ("q", "quit", "exit"): return 0 else: print("1) Install") print("q) Quit") choice = input("Choose an option: ").strip().lower() if not choice or choice in ("1", "install"): return "install" 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 "PY=" 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) user_bin = home / ".local" / "bin" mm_sh = user_bin / "mm" print(f"Checking for shim file:") print(f" mm: {'✓' if mm_sh.exists() else '✗'} ({mm_sh})") print() 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 '✗'}") 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 == "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)") 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}" ) # 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 not args.quiet: print("Installing Deno runtime (local/system)...") rc = _install_deno(args.deno_version) if rc != 0: print("Deno installation failed.", file=sys.stderr) return rc # 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 $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 (CMD shim for all shells; avoids PowerShell execution policy issues) 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' "set \"VENV=!REPO!\\.venv\"\n" "set \"PY=!VENV!\\Scripts\\python.exe\"\n" "set \"ENTRY=!REPO!\\scripts\\cli_entry.py\"\n" "if exist \"!PY!\" (\n" " if defined MM_DEBUG (\n" " echo MM_DEBUG: using venv python at !PY!\n" " \"!PY!\" -c \"import sys; print('sys.executable:', sys.executable); print('sys.path:', sys.path[:5])\"\n" " )\n" " \"!PY!\" \"!ENTRY!\" %*\n" " exit /b !ERRORLEVEL!\n" ")\n" "echo MM: venv python not found at !PY!\n" "if defined MM_DEBUG echo MM_DEBUG: venv python not found, trying system python\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 shims: {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 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" 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" "# Packaged console script in the venv if available\n" 'if [ -x "$VENV/bin/mm" ]; then\n' ' exec "$VENV/bin/mm" "$@"\n' "fi\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())