4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
253 lines
10 KiB
Python
253 lines
10 KiB
Python
"""Shared binary resolution helpers for subprocess-backed CLI adapters.
|
|
|
|
Key public API
|
|
--------------
|
|
|
|
resolve_cli_binary(...)
|
|
Locate an executable using three-stage resolution:
|
|
1. Explicit ``*_BIN`` env override (e.g. ``CODEX_BIN``) — only used when the
|
|
path is runnable; logs a WARNING and falls through otherwise.
|
|
2. ``shutil.which`` PATH lookup for platform-specific binary names.
|
|
3. Conventional install-location fallbacks (npm, volta, pnpm, Homebrew, etc.).
|
|
|
|
diagnose_binary_path(path) -> str | None
|
|
Return a human-readable reason why *path* is not usable, or ``None`` when it
|
|
is fine. Distinguishes the following states so callers can surface actionable
|
|
messages to users:
|
|
|
|
+---------------------------------+----------------------------------------------------+
|
|
| Path state | Returned message (excerpt) |
|
|
+=================================+====================================================+
|
|
| Broken symlink | "'<path>' is a broken symlink (points to '<target>'). Remove or fix it." |
|
|
+---------------------------------+----------------------------------------------------+
|
|
| Does not exist | "'<path>' does not exist." |
|
|
+---------------------------------+----------------------------------------------------+
|
|
| Exists but is not a file | "'<path>' is not a file." |
|
|
+---------------------------------+----------------------------------------------------+
|
|
| File but not executable (Unix) | "'<path>' is not executable. Run: chmod +x <path>" |
|
|
+---------------------------------+----------------------------------------------------+
|
|
| File with wrong extension (Win) | "'<path>' is not a recognised executable (expected .cmd, .exe, .ps1, or .bat)." |
|
|
+---------------------------------+----------------------------------------------------+
|
|
| Valid runnable binary | ``None`` |
|
|
+---------------------------------+----------------------------------------------------+
|
|
|
|
On Windows the executable check uses file extension (``.cmd``, ``.exe``,
|
|
``.ps1``, ``.bat``) mirroring ``is_runnable_binary``, so both functions
|
|
accept and reject the same set of paths.
|
|
|
|
is_runnable_binary(path) -> bool
|
|
Low-level predicate used by ``resolve_cli_binary`` and the CLI wizard.
|
|
Prefer ``diagnose_binary_path`` when a user-facing message is needed.
|
|
|
|
Platform notes
|
|
--------------
|
|
|
|
* Windows binary names include ``.cmd``, ``.exe``, ``.ps1``, ``.bat`` suffixes;
|
|
``candidate_binary_names`` returns all four for a given base name.
|
|
* ``npm_prefix_bin_dirs`` is ``@lru_cache``-d — call ``.cache_clear()`` in tests
|
|
that vary ``NPM_CONFIG_PREFIX`` or ``sys.platform``.
|
|
* ``diagnose_binary_path`` reads the symlink target via ``Path.readlink()``
|
|
(Python ≥ 3.9) for a more actionable error message; falls back silently on
|
|
older hosts or permission errors.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import logging
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from collections.abc import Callable, Sequence
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def candidate_binary_names(binary_name: str) -> tuple[str, ...]:
|
|
"""Return platform-specific executable names for a CLI binary."""
|
|
if sys.platform == "win32":
|
|
return (
|
|
f"{binary_name}.cmd",
|
|
f"{binary_name}.exe",
|
|
f"{binary_name}.ps1",
|
|
f"{binary_name}.bat",
|
|
)
|
|
return (binary_name,)
|
|
|
|
|
|
def _append_candidate_paths(
|
|
candidates: list[str], directory: Path | str, names: tuple[str, ...]
|
|
) -> None:
|
|
base = str(directory).strip()
|
|
if not base:
|
|
return
|
|
root = Path(base).expanduser()
|
|
for name in names:
|
|
candidates.append(str(root / name))
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def npm_prefix_bin_dirs() -> tuple[str, ...]:
|
|
"""Resolve npm global bin directories from env and npm config."""
|
|
env_prefix = os.getenv("NPM_CONFIG_PREFIX", "").strip()
|
|
if not env_prefix:
|
|
# npm often exports lowercase `npm_config_prefix`; accept any casing.
|
|
for key, value in os.environ.items():
|
|
if key.lower() == "npm_config_prefix":
|
|
env_prefix = value.strip()
|
|
if env_prefix:
|
|
break
|
|
if env_prefix:
|
|
if sys.platform == "win32":
|
|
return (str(Path(env_prefix).expanduser()),)
|
|
return (str(Path(env_prefix).expanduser() / "bin"),)
|
|
|
|
try:
|
|
proc = subprocess.run(
|
|
["npm", "config", "get", "prefix"],
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
timeout=2.0,
|
|
check=False,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return ()
|
|
|
|
prefix = (proc.stdout or "").strip()
|
|
if proc.returncode != 0 or not prefix:
|
|
return ()
|
|
|
|
if sys.platform == "win32":
|
|
return (str(Path(prefix).expanduser()),)
|
|
return (str(Path(prefix).expanduser() / "bin"),)
|
|
|
|
|
|
def default_cli_fallback_paths(binary_name: str) -> list[str]:
|
|
"""Build common fallback install locations for a CLI binary."""
|
|
home = Path.home()
|
|
names = candidate_binary_names(binary_name)
|
|
candidates: list[str] = []
|
|
|
|
if sys.platform == "win32":
|
|
_append_candidate_paths(candidates, Path(os.getenv("APPDATA", "")) / "npm", names)
|
|
_append_candidate_paths(
|
|
candidates,
|
|
Path(os.getenv("LOCALAPPDATA", "")) / "Programs" / binary_name,
|
|
names,
|
|
)
|
|
# Match Unix branch: Volta / pnpm globals are common on Windows dev machines too.
|
|
localappdata = os.getenv("LOCALAPPDATA", "").strip()
|
|
volta_home = os.getenv("VOLTA_HOME", "").strip()
|
|
if volta_home:
|
|
_append_candidate_paths(candidates, Path(volta_home) / "bin", names)
|
|
elif localappdata:
|
|
_append_candidate_paths(candidates, Path(localappdata) / "Volta" / "bin", names)
|
|
_append_candidate_paths(candidates, os.getenv("PNPM_HOME", ""), names)
|
|
if localappdata:
|
|
_append_candidate_paths(candidates, Path(localappdata) / "pnpm", names)
|
|
else:
|
|
if sys.platform == "darwin":
|
|
_append_candidate_paths(candidates, "/opt/homebrew/bin", names)
|
|
_append_candidate_paths(candidates, "/usr/local/bin", names)
|
|
_append_candidate_paths(candidates, home / ".local/bin", names)
|
|
_append_candidate_paths(candidates, home / ".npm-global/bin", names)
|
|
_append_candidate_paths(candidates, home / ".volta/bin", names)
|
|
_append_candidate_paths(candidates, os.getenv("PNPM_HOME", ""), names)
|
|
xdg_data_home = os.getenv("XDG_DATA_HOME", "").strip()
|
|
if xdg_data_home:
|
|
_append_candidate_paths(candidates, Path(xdg_data_home) / "pnpm", names)
|
|
|
|
for npm_dir in npm_prefix_bin_dirs():
|
|
_append_candidate_paths(candidates, npm_dir, names)
|
|
|
|
deduped: list[str] = []
|
|
seen: set[str] = set()
|
|
for candidate in candidates:
|
|
normalized = str(Path(candidate).expanduser())
|
|
if normalized in seen:
|
|
continue
|
|
seen.add(normalized)
|
|
deduped.append(normalized)
|
|
return deduped
|
|
|
|
|
|
def is_runnable_binary(path: str) -> bool:
|
|
"""Return True when a path points to an executable binary/script."""
|
|
p = Path(path)
|
|
if not p.is_file():
|
|
return False
|
|
if sys.platform == "win32":
|
|
return p.suffix.lower() in {".cmd", ".exe", ".ps1", ".bat"} or os.access(p, os.X_OK)
|
|
return os.access(p, os.X_OK)
|
|
|
|
|
|
def diagnose_binary_path(path: str) -> str | None:
|
|
"""Return a human-readable reason why *path* is not runnable, or None if it is.
|
|
|
|
Distinguishes broken symlinks from missing files so callers can surface a
|
|
more actionable message than a generic "not found".
|
|
"""
|
|
p = Path(path)
|
|
if p.is_symlink() and not p.exists():
|
|
target = ""
|
|
with contextlib.suppress(OSError, AttributeError):
|
|
target = f" (points to '{p.readlink()}')"
|
|
return f"'{path}' is a broken symlink{target}. Remove or fix it."
|
|
if not p.exists():
|
|
return f"'{path}' does not exist."
|
|
if not p.is_file():
|
|
return f"'{path}' is not a file."
|
|
if sys.platform == "win32":
|
|
if p.suffix.lower() not in {".cmd", ".exe", ".ps1", ".bat"} and not os.access(p, os.X_OK):
|
|
return f"'{path}' is not a recognised executable (expected .cmd, .exe, .ps1, or .bat)."
|
|
elif not os.access(p, os.X_OK):
|
|
return f"'{path}' is not executable. Run: chmod +x {path}"
|
|
return None
|
|
|
|
|
|
def resolve_cli_binary(
|
|
*,
|
|
explicit_env_key: str,
|
|
binary_names: Sequence[str],
|
|
fallback_paths: Sequence[str] | Callable[[], Sequence[str]],
|
|
which_resolver: Callable[[str], str | None] | None = None,
|
|
runnable_check: Callable[[str], bool] | None = None,
|
|
) -> str | None:
|
|
"""Resolve an executable path from env override, PATH lookup, and fallbacks.
|
|
|
|
``which_resolver`` and ``runnable_check`` default to ``shutil.which`` and
|
|
``is_runnable_binary`` respectively. They are looked up at *call time* (not
|
|
bound as default parameter values) so that test patches on this module's
|
|
``shutil.which`` / ``is_runnable_binary`` take effect without callers having
|
|
to pass explicit overrides.
|
|
"""
|
|
_which = which_resolver if which_resolver is not None else shutil.which
|
|
_runnable = runnable_check if runnable_check is not None else is_runnable_binary
|
|
|
|
explicit = os.getenv(explicit_env_key, "").strip()
|
|
if explicit:
|
|
if _runnable(explicit):
|
|
return explicit
|
|
reason = diagnose_binary_path(explicit)
|
|
logger.warning(
|
|
"%s is set but unusable — falling back to PATH/defaults. %s",
|
|
explicit_env_key,
|
|
reason or "Not a runnable file.",
|
|
)
|
|
|
|
for name in binary_names:
|
|
found = _which(name)
|
|
if found:
|
|
return found
|
|
|
|
resolved_fallback_paths = fallback_paths() if callable(fallback_paths) else fallback_paths
|
|
for candidate in resolved_fallback_paths:
|
|
if _runnable(candidate):
|
|
return candidate
|
|
return None
|