diff --git a/anti-slop-brain/install.sh b/anti-slop-brain/install.sh index 77ed107..ae83ca6 100755 --- a/anti-slop-brain/install.sh +++ b/anti-slop-brain/install.sh @@ -72,12 +72,33 @@ install_one() { echo "Anti-Slop Brain installed to ${dest}" } +# The Gemini loader edit is the only step that needs an interpreter, so resolve +# one lazily rather than at startup: --help must still work on a machine with no +# Python at all. Prefer python3. macOS has not shipped a bare `python` since +# Monterey removed the Python 2 stub, so hardcoding it fails there with 127. +resolve_python() { + if [ -n "${PYTHON:-}" ]; then + return + fi + local candidate + for candidate in python3 python; do + if command -v "${candidate}" >/dev/null 2>&1 && + "${candidate}" -c 'import sys; sys.exit(0 if sys.version_info[0] == 3 else 1)' >/dev/null 2>&1; then + PYTHON="${candidate}" + return + fi + done + echo "ERROR: no Python 3 interpreter on PATH. Looked for python3, then python." >&2 + exit 1 +} + install_gemini() { local dest="${base_home}/.gemini/anti-slop-brain" local loader="${base_home}/.gemini/GEMINI.md" install_one "${dest}" mkdir -p "$(dirname "${loader}")" - python - "${loader}" "@./anti-slop-brain/GEMINI.md" <<'PY' + resolve_python + "${PYTHON}" - "${loader}" "@./anti-slop-brain/GEMINI.md" <<'PY' from __future__ import annotations import re diff --git a/anti-slop-brain/tests/test_pipeline.py b/anti-slop-brain/tests/test_pipeline.py index bb6c985..6fd32af 100644 --- a/anti-slop-brain/tests/test_pipeline.py +++ b/anti-slop-brain/tests/test_pipeline.py @@ -4,6 +4,7 @@ import hashlib import json import os +import shutil import subprocess import sys import tempfile @@ -95,6 +96,48 @@ def test_demo_build_is_reproducible() -> None: ) +def test_installers_run_without_a_bare_python() -> None: + """The installers must not assume a `python` on PATH. + + Regression: `install.sh` and `uninstall.sh` invoked bare `python` for the + Gemini loader edit. macOS has shipped no `python` since Monterey removed + the Python 2 stub, so `./install.sh --target all` exited 127 after it had + already copied five skill trees, leaving a half-installed surface and no + loader. `uninstall.sh` failed at the same point and stranded the loader + block inside `GEMINI.md`, which the next install then had to repair. + + The ambient-PATH run in `main` cannot catch this. `actions/setup-python` + puts a `python` on PATH, so CI is green, and so is any developer machine + with a virtualenv active. This test builds a sanitized PATH carrying + `python3` and the utilities the scripts shell out to, and no `python` under + any name, which is the arrangement that actually failed. + """ + with tempfile.TemporaryDirectory(prefix="anti-slop-brain-nopython-") as tmp: + root = Path(tmp) + bin_dir = root / "bin" + bin_dir.mkdir() + for tool in ("bash", "cp", "mkdir", "rm", "chmod", "dirname", "find"): + resolved = shutil.which(tool) + assert resolved, f"cannot build a sanitized PATH without {tool}" + (bin_dir / tool).symlink_to(resolved) + # The interpreter is present under its versioned name only. + (bin_dir / "python3").symlink_to(PY) + assert shutil.which("python", path=str(bin_dir)) is None, "sanitized PATH still exposes a bare python" + + env = {"PATH": str(bin_dir), "ANTI_SLOP_BRAIN_INSTALL_HOME": str(root / "home")} + loader = root / "home" / ".gemini" / "GEMINI.md" + # subprocess resolves argv[0] against the parent PATH rather than the + # env passed here, so bash is named absolutely. Everything the script + # itself looks up goes through the sanitized PATH. + bash = str(bin_dir / "bash") + + run_cmd([bash, "install.sh", "--target", "gemini"], env=env) + assert "anti-slop-brain-install:start" in loader.read_text(encoding="utf-8"), "install wrote no loader block" + + run_cmd([bash, "uninstall.sh", "--target", "gemini"], env=env) + assert not loader.exists(), "uninstall left the loader block behind" + + def main() -> int: run(["-m", "compileall", "scripts", "anti_slop_brain", "tests"]) run(["scripts/lint_vault.py", "--vault", "assets/template-brain", "--template"]) @@ -145,6 +188,7 @@ def main() -> int: assert not (Path(tmp) / ".gemini" / "GEMINI.md").exists() run_cmd(["bash", "uninstall.sh", "--target", "custom", "--path", str(custom_root)], env=env) assert not (custom_root / "anti-slop-brain").exists() + test_installers_run_without_a_bare_python() print("Pipeline tests passed") return 0 diff --git a/anti-slop-brain/uninstall.sh b/anti-slop-brain/uninstall.sh index 56d0a11..e5a4a15 100755 --- a/anti-slop-brain/uninstall.sh +++ b/anti-slop-brain/uninstall.sh @@ -40,12 +40,34 @@ remove_one() { fi } +# The Gemini loader cleanup is the only step that needs an interpreter, so +# resolve one lazily rather than at startup: --help must still work on a machine +# with no Python at all. Prefer python3. macOS has not shipped a bare `python` +# since Monterey removed the Python 2 stub, so hardcoding it fails with 127, +# which would strand the loader block in GEMINI.md after an uninstall. +resolve_python() { + if [ -n "${PYTHON:-}" ]; then + return + fi + local candidate + for candidate in python3 python; do + if command -v "${candidate}" >/dev/null 2>&1 && + "${candidate}" -c 'import sys; sys.exit(0 if sys.version_info[0] == 3 else 1)' >/dev/null 2>&1; then + PYTHON="${candidate}" + return + fi + done + echo "ERROR: no Python 3 interpreter on PATH. Looked for python3, then python." >&2 + exit 1 +} + remove_gemini() { local dir="${base_home}/.gemini/anti-slop-brain" local loader="${base_home}/.gemini/GEMINI.md" remove_one "${dir}" if [ -f "${loader}" ]; then - python - "${loader}" <<'PY' + resolve_python + "${PYTHON}" - "${loader}" <<'PY' from __future__ import annotations import re