diff --git a/loopx/bootstrap.py b/loopx/bootstrap.py index ac1cf757fe..15b099870d 100644 --- a/loopx/bootstrap.py +++ b/loopx/bootstrap.py @@ -38,8 +38,8 @@ ) from .global_registry import sync_project_registry_to_global from .install_contract import ( - ARCHIVE_FALLBACK_INSTALL_COMMAND, - DEFAULT_INSTALL_REPAIR_COMMAND, + archive_fallback_install_command, + install_repair_command, ) from .orchestration import ( DEFAULT_ORCHESTRATION_MODE, @@ -504,8 +504,8 @@ def bootstrap_project( "Fix global registry write access, then rerun this command.", "Use --no-global-sync only for an explicit local-only setup.", ], - "install_repair_command": DEFAULT_INSTALL_REPAIR_COMMAND, - "archive_fallback_install_command": ARCHIVE_FALLBACK_INSTALL_COMMAND, + "install_repair_command": install_repair_command(), + "archive_fallback_install_command": archive_fallback_install_command(), "install_repair_note": ( "If this local LoopX install is missing or stale, repair the PyPI distribution " "and packaged workflow skills, then confirm with loopx doctor before continuing." @@ -665,8 +665,8 @@ def bootstrap_project( f"loopx --registry {runtime_root / 'registry.global.json'} status", f"loopx --registry {relative_state_file(project, registry_path)} history --goal-id {goal_id}", ], - "install_repair_command": DEFAULT_INSTALL_REPAIR_COMMAND, - "archive_fallback_install_command": ARCHIVE_FALLBACK_INSTALL_COMMAND, + "install_repair_command": install_repair_command(), + "archive_fallback_install_command": archive_fallback_install_command(), "install_repair_note": ( "If this local LoopX install is missing or stale, repair the PyPI distribution " "and packaged workflow skills, then confirm with loopx doctor before continuing." diff --git a/loopx/doctor.py b/loopx/doctor.py index 9a6f1d4b64..2724b0324a 100644 --- a/loopx/doctor.py +++ b/loopx/doctor.py @@ -17,7 +17,11 @@ PROMOTION_READINESS_RUNTIME_INDEX, ) from .control_plane.runtime.time import chronology_key -from .install_contract import NO_CLONE_INSTALL_URL +from .install_contract import ( + NO_CLONE_INSTALL_URL, + local_install_command, + no_clone_upgrade_command, +) from .paths import configured_runtime_route, default_runtime_route, global_registry_path from .python_install_owner import PythonInstallOwner, python_distribution_upgrade_command, resolve_python_install_owner from .capabilities.project_skill_delivery import discover_project_scoped_skill_ids @@ -81,62 +85,6 @@ } -def _powershell_literal(value: str | Path) -> str: - return "'" + str(value).replace("'", "''") + "'" - - -def local_install_command(repo_root: Path, *, skip_skills: bool = False) -> str: - if os.name == "nt": - return ( - "pwsh -NoLogo -NoProfile -File " - f"{_powershell_literal(repo_root / 'scripts' / 'install-windows.ps1')} " - f"-Python {_powershell_literal(sys.executable)}" - + (" -SkipSkills" if skip_skills else "") - ) - command = str(repo_root / "scripts" / "install-local.sh") - return f"LOOPX_INSTALL_SKILL=0 {command}" if skip_skills else command - - -def no_clone_upgrade_command( - source_ref: Any = None, - *, - doctor_agent_type: str | None = None, - skip_skills: bool = False, -) -> str: - if os.name == "nt": - repo_root = Path(__file__).resolve().parents[1] - doctor_agent_arg = ( - f" --agent-type {_powershell_literal(doctor_agent_type)}" - if doctor_agent_type - else "" - ) - return ( - f"{local_install_command(repo_root, skip_skills=skip_skills)}\n" - f"loopx doctor{doctor_agent_arg}" - ) - ref = str(source_ref or "").strip() - installer = f"curl -fsSL {NO_CLONE_INSTALL_URL}" - doctor_agent_arg = ( - f" --agent-type {shlex.quote(doctor_agent_type)}" - if doctor_agent_type - else "" - ) - install_env: list[str] = [] - if ref and ref != "stable": - install_env.append(f"LOOPX_REF={shlex.quote(ref)}") - if skip_skills: - install_env.append("LOOPX_INSTALL_SKILL=0") - if install_env: - installer = f"{installer} | env {' '.join(install_env)} bash" - else: - installer = f"{installer} | bash" - return ( - f"{installer}\n" - 'export PATH="$HOME/.local/bin:$PATH"\n' - f"loopx doctor{doctor_agent_arg}" - ) - - def user_local_bin() -> Path: return Path.home() / ".local" / "bin" diff --git a/loopx/install_contract.py b/loopx/install_contract.py index f6ff897860..ea7469587d 100644 --- a/loopx/install_contract.py +++ b/loopx/install_contract.py @@ -1,3 +1,21 @@ +"""Install, repair, and fallback command text printed to LoopX operators. + +One platform-aware owner keeps the bootstrap/connect hint, the doctor freshness +command, and the archive fallback consistent. POSIX keeps the documented public +defaults; native Windows has neither a ``python3`` launcher nor Bash, so it +names the interpreter that runs here and prints the snapshot installer only for a +checkout that actually ships it. +""" + +from __future__ import annotations + +import os +import shlex +import sys +from pathlib import Path +from typing import Any + + NO_CLONE_INSTALL_URL = "https://loopx-project.github.io/loopx/install.sh" DEFAULT_INSTALL_COMMAND = "python3 -m pip install --upgrade loopx" @@ -12,3 +30,110 @@ 'export PATH="$HOME/.local/bin:$PATH"\n' "loopx doctor" ) + + +def _powershell_literal(value: str | Path) -> str: + return "'" + str(value).replace("'", "''") + "'" + + +def _is_windows_host() -> bool: + """Whether printed commands target PowerShell instead of a POSIX shell.""" + + return os.name == "nt" + + +def _checkout_snapshot_installer(repo_root: Path) -> Path | None: + """The snapshot installer only when this module runs from the checkout that owns it. + + An installed distribution places this module under site-packages, which does not + ship ``scripts/install-windows.ps1``. Printing that path would hand the operator a + ``pwsh -File`` command PowerShell rejects, so the project marker and the script + itself decide whether the checkout route is real. + """ + + installer = repo_root / "scripts" / "install-windows.ps1" + if installer.is_file() and (repo_root / "pyproject.toml").is_file(): + return installer + return None + + +def _windows_pypi_upgrade_command(*, doctor_agent_arg: str, skip_skills: bool) -> str: + """No-clone upgrade through the interpreter that owns this installation.""" + + lines = [f"& {_powershell_literal(sys.executable)} -m pip install --upgrade loopx"] + if not skip_skills: + lines.append(DEFAULT_WORKFLOW_SKILL_INSTALL_COMMAND) + lines.append(f"loopx doctor{doctor_agent_arg}") + return "\n".join(lines) + + +def local_install_command(repo_root: Path, *, skip_skills: bool = False) -> str: + if _is_windows_host(): + return ( + "pwsh -NoLogo -NoProfile -File " + f"{_powershell_literal(repo_root / 'scripts' / 'install-windows.ps1')} " + f"-Python {_powershell_literal(sys.executable)}" + + (" -SkipSkills" if skip_skills else "") + ) + command = str(repo_root / "scripts" / "install-local.sh") + return f"LOOPX_INSTALL_SKILL=0 {command}" if skip_skills else command + + +def no_clone_upgrade_command( + source_ref: Any = None, + *, + doctor_agent_type: str | None = None, + skip_skills: bool = False, +) -> str: + if _is_windows_host(): + repo_root = Path(__file__).resolve().parents[1] + doctor_agent_arg = ( + f" --agent-type {_powershell_literal(doctor_agent_type)}" + if doctor_agent_type + else "" + ) + if _checkout_snapshot_installer(repo_root) is not None: + return ( + f"{local_install_command(repo_root, skip_skills=skip_skills)}\n" + f"loopx doctor{doctor_agent_arg}" + ) + return _windows_pypi_upgrade_command( + doctor_agent_arg=doctor_agent_arg, skip_skills=skip_skills + ) + ref = str(source_ref or "").strip() + installer = f"curl -fsSL {NO_CLONE_INSTALL_URL}" + doctor_agent_arg = ( + f" --agent-type {shlex.quote(doctor_agent_type)}" + if doctor_agent_type + else "" + ) + install_env: list[str] = [] + if ref and ref != "stable": + install_env.append(f"LOOPX_REF={shlex.quote(ref)}") + if skip_skills: + install_env.append("LOOPX_INSTALL_SKILL=0") + if install_env: + installer = f"{installer} | env {' '.join(install_env)} bash" + else: + installer = f"{installer} | bash" + return ( + f"{installer}\n" + 'export PATH="$HOME/.local/bin:$PATH"\n' + f"loopx doctor{doctor_agent_arg}" + ) + + +def install_repair_command() -> str: + """PyPI repair guidance that runs as printed on this host.""" + + if _is_windows_host(): + return _windows_pypi_upgrade_command(doctor_agent_arg="", skip_skills=False) + return DEFAULT_INSTALL_REPAIR_COMMAND + + +def archive_fallback_install_command() -> str: + """Archive fallback guidance that runs as printed on this host.""" + + if _is_windows_host(): + return no_clone_upgrade_command() + return ARCHIVE_FALLBACK_INSTALL_COMMAND diff --git a/tests/test_install_hint_platform.py b/tests/test_install_hint_platform.py new file mode 100644 index 0000000000..e39bd4541f --- /dev/null +++ b/tests/test_install_hint_platform.py @@ -0,0 +1,361 @@ +"""Install/repair guidance LoopX prints must run on the host that prints it. + +The bootstrap/connect hint is the first repair instruction a new operator sees. +Native Windows has no ``python3`` launcher and no Bash, while ``loopx doctor`` +already names the interpreter and snapshot installer this host owns. These +tests pin that platform rule together with the PowerShell invocation semantics +the printed Windows commands need, so the guidance is executable and not only +worded. They also pin the layout rule: the fallback names the snapshot +installer only while the checkout that owns this module ships that script, and +an installed distribution recovers through the interpreter's own pip. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +from loopx import install_contract +from loopx.bootstrap import bootstrap_project + +POSIX_ONLY_TOKENS = ("python3 ", "| bash", "export PATH=") + + +PLAIN_WINDOWS_INTERPRETER = r"C:\Python311\python.exe" + +WINDOWS_INTERPRETERS = ( + PLAIN_WINDOWS_INTERPRETER, + r"C:\Program Files\Python311\python.exe", + r"C:\O'Brien\python.exe", +) + +PWSH = shutil.which("pwsh") + + +def powershell_literal(value: str) -> str: + """PowerShell single-quoted literal, written independently of the owner.""" + + return "'" + value.replace("'", "''") + "'" + + +def pretend_windows_host( + monkeypatch: pytest.MonkeyPatch, interpreter: str | None = None +) -> None: + """Exercise the Windows branch on any host without switching ``os.name``.""" + + monkeypatch.setattr(install_contract, "_is_windows_host", lambda: True) + if interpreter is not None: + monkeypatch.setattr(sys, "executable", interpreter) + + +def pretend_module_root(monkeypatch: pytest.MonkeyPatch, root: Path) -> Path: + """Install the owner module under ``root`` the way a wheel layout would.""" + + module_file = root / "loopx" / "install_contract.py" + module_file.parent.mkdir(parents=True, exist_ok=True) + module_file.write_text("# fixture module owned by this layout\n", encoding="utf-8") + monkeypatch.setattr(install_contract, "__file__", str(module_file)) + return module_file.resolve().parents[1] + + +def checkout_layout(root: Path) -> Path: + """A LoopX checkout that ships the Windows snapshot installer.""" + + installer = root / "scripts" / "install-windows.ps1" + installer.parent.mkdir(parents=True, exist_ok=True) + installer.write_text("# fixture snapshot installer\n", encoding="utf-8") + (root / "pyproject.toml").write_text( + '[project]\nname = "loopx"\n', encoding="utf-8" + ) + return installer + + +def powershell_parse_errors(pwsh: str, script: Path) -> str: + probe = ( + "$errors = $null;" + "[System.Management.Automation.Language.Parser]::ParseFile(" + f"{powershell_literal(str(script))}, [ref]$null, [ref]$errors) > $null;" + "$errors | ForEach-Object { $_.Message }" + ) + completed = subprocess.run( + [pwsh, "-NoLogo", "-NoProfile", "-Command", probe], + capture_output=True, + text=True, + timeout=180, + ) + assert completed.returncode == 0, completed.stderr + return completed.stdout.strip() + + +def bootstrap_hint(tmp_path: Path) -> tuple[str, str]: + registry = tmp_path / ".loopx" / "registry.json" + registry.parent.mkdir(parents=True, exist_ok=True) + payload = bootstrap_project( + project=tmp_path, + registry_path=registry, + runtime_root=tmp_path / "runtime", + goal_id="install-hint-goal", + objective="Print install guidance.", + domain="test", + role="controller", + parent_goal_id=None, + state_file=None, + goal_doc=None, + adapter_kind="generic_project_goal_v0", + adapter_status="connected", + next_probe=None, + spawn_allowed=False, + max_children=0, + allowed_domains=[], + write_scope=[], + force=False, + dry_run=True, + sync_global=False, + ) + return str(payload["install_repair_command"]), str( + payload["archive_fallback_install_command"] + ) + + +@pytest.mark.parametrize("os_name", ["posix", "nt"]) +def test_install_hint_commands_follow_the_host_platform( + monkeypatch: pytest.MonkeyPatch, os_name: str +) -> None: + monkeypatch.setattr(install_contract, "_is_windows_host", lambda: os_name == "nt") + repair = install_contract.install_repair_command() + fallback = install_contract.archive_fallback_install_command() + + if os_name == "nt": + for command in (repair, fallback): + for token in POSIX_ONLY_TOKENS: + assert token not in command, command + assert repair.splitlines()[0] == ( + f"& {powershell_literal(sys.executable)} -m pip install --upgrade loopx" + ) + assert "loopx workflow-skills --install" in repair + assert "loopx doctor" in repair + checkout_installer = ( + Path(install_contract.__file__).resolve().parents[1] + / "scripts" + / "install-windows.ps1" + ) + assert checkout_installer.is_file() + assert powershell_literal(str(checkout_installer)) in fallback + assert "curl" not in fallback + else: + assert repair == install_contract.DEFAULT_INSTALL_REPAIR_COMMAND + assert fallback == install_contract.ARCHIVE_FALLBACK_INSTALL_COMMAND + + +@pytest.mark.parametrize("interpreter", WINDOWS_INTERPRETERS) +def test_windows_repair_command_invokes_the_interpreter_with_the_call_operator( + monkeypatch: pytest.MonkeyPatch, interpreter: str +) -> None: + pretend_windows_host(monkeypatch, interpreter) + + repair = install_contract.install_repair_command() + + assert repair.splitlines()[0] == ( + f"& {powershell_literal(interpreter)} -m pip install --upgrade loopx" + ) + + +def test_windows_fallback_prints_the_owned_checkout_installer( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + checkout_layout(tmp_path / "checkout") + pretend_windows_host(monkeypatch, r"C:\Python311\python.exe") + root = pretend_module_root(monkeypatch, tmp_path / "checkout") + + fallback = install_contract.archive_fallback_install_command() + + installer = root / "scripts" / "install-windows.ps1" + assert installer.is_file() + assert fallback.splitlines() == [ + "pwsh -NoLogo -NoProfile -File " + f"{powershell_literal(str(installer))} " + f"-Python {powershell_literal(PLAIN_WINDOWS_INTERPRETER)}", + "loopx doctor", + ] + + +@pytest.mark.parametrize( + "layout_files", + [(), ("scripts/install-windows.ps1",), ("pyproject.toml",)], + ids=["installed-distribution", "unowned-script", "checkout-without-installer"], +) +def test_windows_fallback_without_an_owned_installer_uses_the_interpreter_pip( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, layout_files: tuple[str, ...] +) -> None: + layout = tmp_path / "site-packages" + for relative in layout_files: + path = layout / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("# not the checkout that owns the module\n", encoding="utf-8") + pretend_windows_host(monkeypatch, r"C:\Python311\python.exe") + pretend_module_root(monkeypatch, layout) + + fallback = install_contract.archive_fallback_install_command() + + assert fallback.splitlines() == [ + f"& {powershell_literal(PLAIN_WINDOWS_INTERPRETER)} " + "-m pip install --upgrade loopx", + "loopx workflow-skills --install", + "loopx doctor", + ] + + +def test_windows_installed_fallback_keeps_doctor_options_and_skips_owned_skills( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + pretend_windows_host(monkeypatch, PLAIN_WINDOWS_INTERPRETER) + pretend_module_root(monkeypatch, tmp_path / "site-packages") + + command = install_contract.no_clone_upgrade_command( + doctor_agent_type="codex", skip_skills=True + ) + + assert command.splitlines() == [ + f"& {powershell_literal(PLAIN_WINDOWS_INTERPRETER)} " + "-m pip install --upgrade loopx", + f"loopx doctor --agent-type {powershell_literal('codex')}", + ] + + +@pytest.mark.skipif(PWSH is None, reason="requires pwsh to parse the printed commands") +@pytest.mark.parametrize("layout", ["checkout", "installed"]) +@pytest.mark.parametrize("interpreter", WINDOWS_INTERPRETERS) +def test_windows_commands_parse_as_powershell( + monkeypatch: pytest.MonkeyPatch, interpreter: str, tmp_path: Path, layout: str +) -> None: + pwsh = PWSH + assert pwsh is not None + pretend_windows_host(monkeypatch, interpreter) + root = tmp_path / layout + if layout == "checkout": + checkout_layout(root) + pretend_module_root(monkeypatch, root) + commands = { + "repair": install_contract.install_repair_command(), + "fallback": install_contract.archive_fallback_install_command(), + } + + for name, command in commands.items(): + script = tmp_path / f"{name}-{layout}-hint.ps1" + script.write_text(command, encoding="utf-8") + + errors = powershell_parse_errors(pwsh, script) + + assert errors == "", (name, layout, errors) + + +@pytest.mark.skipif(PWSH is None, reason="requires pwsh to run the printed command") +@pytest.mark.parametrize( + "command_name", ["install_repair_command", "installed_fallback"] +) +def test_windows_interpreter_witness_runs_a_real_executable( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, command_name: str +) -> None: + pwsh = PWSH + assert pwsh is not None + pretend_windows_host(monkeypatch) + if command_name == "installed_fallback": + pretend_module_root(monkeypatch, tmp_path / "site-packages") + command = install_contract.archive_fallback_install_command() + else: + command = install_contract.install_repair_command() + interpreter_line = command.splitlines()[0] + witness = interpreter_line.replace( + "-m pip install --upgrade loopx", "-c \"print('loopx-witness-ok')\"" + ) + assert witness != interpreter_line + + completed = subprocess.run( + [pwsh, "-NoLogo", "-NoProfile", "-Command", witness], + capture_output=True, + text=True, + timeout=180, + ) + + assert completed.returncode == 0, completed.stderr + assert "loopx-witness-ok" in completed.stdout + + +def test_installed_layout_prints_a_runnable_pip_fallback(tmp_path: Path) -> None: + """Load the shipped module from an installed layout, not from a checkout.""" + + layout = tmp_path / "site-packages" + (layout / "loopx").mkdir(parents=True) + source = Path(install_contract.__file__).resolve().parent + for name in ("__init__.py", "install_contract.py"): + shutil.copyfile(source / name, layout / "loopx" / name) + probe = ( + "import loopx.install_contract as owner;" + "print(owner.__file__);" + "owner._is_windows_host = lambda: True;" + "print(owner.archive_fallback_install_command())" + ) + environment = { + key: value for key, value in os.environ.items() if key != "PYTHONPATH" + } + completed = subprocess.run( + [sys.executable, "-c", probe], + cwd=layout, + env=environment, + capture_output=True, + text=True, + timeout=60, + ) + + assert completed.returncode == 0, completed.stderr + lines = completed.stdout.splitlines() + assert ( + Path(lines[0]).resolve() == (layout / "loopx" / "install_contract.py").resolve() + ) + assert lines[1:] == [ + f"& {powershell_literal(sys.executable)} -m pip install --upgrade loopx", + "loopx workflow-skills --install", + "loopx doctor", + ] + + +def test_bootstrap_payload_prints_commands_this_host_can_run(tmp_path: Path) -> None: + repair, fallback = bootstrap_hint(tmp_path) + assert "loopx doctor" in repair + assert "loopx doctor" in fallback + if os.name == "nt": + for command in (repair, fallback): + for token in POSIX_ONLY_TOKENS: + assert token not in command, command + assert Path(sys.executable).is_file() + assert powershell_literal(sys.executable) in repair + checkout_installer = ( + Path(install_contract.__file__).resolve().parents[1] + / "scripts" + / "install-windows.ps1" + ) + assert checkout_installer.is_file() + assert powershell_literal(str(checkout_installer)) in fallback + else: + assert repair == install_contract.DEFAULT_INSTALL_REPAIR_COMMAND + assert fallback == install_contract.ARCHIVE_FALLBACK_INSTALL_COMMAND + + +def test_bootstrap_payload_without_the_checkout_installer_uses_pip( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + pretend_windows_host(monkeypatch, r"C:\Python311\python.exe") + pretend_module_root(monkeypatch, tmp_path / "site-packages") + + _, fallback = bootstrap_hint(tmp_path) + + assert fallback.splitlines()[0] == ( + f"& {powershell_literal(PLAIN_WINDOWS_INTERPRETER)} " + "-m pip install --upgrade loopx" + ) + assert "install-windows.ps1" not in fallback