diff --git a/tests/test_install_portal_clis.py b/tests/test_install_portal_clis.py index 2b02bba4..ecc930c2 100644 --- a/tests/test_install_portal_clis.py +++ b/tests/test_install_portal_clis.py @@ -121,5 +121,58 @@ class InstallPortalClis(unittest.TestCase): self.assertIn(f"would install {cli.parent.name}", proc.stdout) +class FindBun(unittest.TestCase): + def setUp(self): + directory = TemporaryDirectory() + self.addCleanup(directory.cleanup) + self.root = Path(directory.name) + + def test_find_bun_prefers_path(self): + import shutil + orig_which = shutil.which + shutil.which = lambda name: "/custom/bin/bun" if name == "bun" else None + self.addCleanup(setattr, shutil, "which", orig_which) + + self.assertEqual(install_portal_clis.find_bun(), "/custom/bin/bun") + + def test_find_bun_falls_back_to_candidate_directory(self): + import shutil + orig_which = shutil.which + shutil.which = lambda name: None + self.addCleanup(setattr, shutil, "which", orig_which) + + binary = "bun.exe" if sys.platform == "win32" else "bun" + fake_bin = self.root / ".bun" / "bin" + fake_bin.mkdir(parents=True) + fake_bun = fake_bin / binary + fake_bun.write_text("#!/bin/sh\n", encoding="utf-8") + if sys.platform != "win32": + fake_bun.chmod(0o755) + + found = install_portal_clis.find_bun(candidate_dirs=(str(fake_bin),)) + self.assertEqual(found, str(fake_bun)) + + def test_find_bun_returns_none_when_missing(self): + import shutil + orig_which = shutil.which + shutil.which = lambda name: None + self.addCleanup(setattr, shutil, "which", orig_which) + + self.assertIsNone(install_portal_clis.find_bun(candidate_dirs=(str(self.root / "empty"),))) + + def test_missing_bun_message_suggests_export(self): + output = io.StringIO() + orig_find = install_portal_clis.find_bun + install_portal_clis.find_bun = lambda *args, **kwargs: None + self.addCleanup(setattr, install_portal_clis, "find_bun", orig_find) + + with redirect_stdout(output): + rc = install_portal_clis.main([]) + self.assertEqual(rc, 1) + text = output.getvalue() + self.assertIn("bun was not found on PATH", text) + self.assertIn("export PATH=", text) + + if __name__ == "__main__": unittest.main() diff --git a/tools/install_portal_clis.py b/tools/install_portal_clis.py index 1e0b6d99..1d2fee73 100644 --- a/tools/install_portal_clis.py +++ b/tools/install_portal_clis.py @@ -30,6 +30,7 @@ install failed. import argparse import json +import os import shutil import subprocess import sys @@ -96,6 +97,30 @@ def install(cli_dirs: list[Path], bun: str, dry_run: bool, root: Path = ROOT, ru return 0 +_BUN_CANDIDATE_DIRS = ( + "~/.bun/bin", # default for bun.sh installer (curl -fsSL https://bun.sh/install | bash) + "/opt/homebrew/bin", # Homebrew on Apple Silicon macOS + "/usr/local/bin", # Homebrew on Intel macOS / standard Linux + "/home/linuxbrew/.linuxbrew/bin", # Linuxbrew system + "~/.linuxbrew/bin", # Linuxbrew user + "~/.local/bin", # pipx / user local + "/usr/bin", # distro packages +) + + +def find_bun(candidate_dirs: tuple[str, ...] = _BUN_CANDIDATE_DIRS) -> str | None: + """Return `bun` on PATH, or at a standard install location.""" + found = shutil.which("bun") + if found: + return found + binary = "bun.exe" if sys.platform == "win32" else "bun" + for dir_path in candidate_dirs: + candidate = Path(dir_path).expanduser() / binary + if candidate.is_file() and (sys.platform == "win32" or os.access(candidate, os.X_OK)): + return str(candidate) + return None + + def _force_utf8_output() -> None: """Write UTF-8 whatever the host's default encoding is (a fork's portal name may not be ASCII).""" for stream in (sys.stdout, sys.stderr): @@ -104,20 +129,29 @@ def _force_utf8_output() -> None: reconfigure(encoding="utf-8") -def main() -> int: +def main(argv=None) -> int: _force_utf8_output() ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--dry-run", action="store_true", help="list what would be installed, run nothing") - args = ap.parse_args() + args = ap.parse_args(argv) cli_dirs = discover() if not cli_dirs: print("No portal CLIs found under .agents/skills/*/cli/ - nothing to install.") return 0 - bun = shutil.which("bun") + bun = find_bun() if not bun and not args.dry_run: - print("bun is not on PATH. Install it from https://bun.sh, open a new shell, and re-run.") + print( + "bun was not found on PATH and was not found at any of the usual install locations.\n" + ' - installed via bun.sh? export PATH="$HOME/.bun/bin:$PATH"\n' + " - installed via Homebrew/Linuxbrew? brew install bun\n" + " - installed elsewhere? reinstall to a directory on PATH" + ) return 1 + if bun: + bun_dir = str(Path(bun).parent) + if bun_dir not in os.environ.get("PATH", "").split(os.pathsep): + os.environ["PATH"] = bun_dir + os.pathsep + os.environ.get("PATH", "") return install(cli_dirs, bun or "bun", args.dry_run)