v0.165.0
  1"""
  2Oxc standalone binary management for plain-code.
  3
  4Downloads and manages oxlint (linter) and oxfmt (formatter) binaries
  5from the oxc-project/oxc GitHub releases.
  6"""
  7
  8import io
  9import os
 10import platform
 11import subprocess
 12import sys
 13import tarfile
 14import tomllib
 15import zipfile
 16from pathlib import Path
 17
 18import click
 19import httpx
 20import tomlkit
 21from plain.runtime import PLAIN_CACHE_PATH, PLAIN_TEMP_PATH
 22from plain.utils.version import compare_versions
 23
 24TAG_PREFIX = "apps_v"
 25
 26# Older versions resolve ignore patterns differently, so we don't support them.
 27MIN_VERSION = (1, 75, 0)
 28
 29
 30def check_min_version(version: str) -> None:
 31    """Raise when `version` predates the oldest Oxc we support."""
 32    minimum = ".".join(str(part) for part in MIN_VERSION)
 33    if compare_versions(version, minimum) < 0:
 34        raise RuntimeError(
 35            f"Oxc {version} is too old (minimum is {minimum}) — run `plain code update`"
 36        )
 37
 38
 39# Committed third-party code that we don't want to lint or format. Everything
 40# else worth skipping (node_modules, .venv, htmlcov, .pytest_cache) is already
 41# gitignored, and both tools read .gitignore on their own.
 42#
 43# These go on the command line rather than in a config file, because config-file
 44# `ignorePatterns` only match files underneath the config file's own directory
 45# and ours ships inside the installed package.
 46IGNORE_PATTERNS = [
 47    "**/vendor/**",
 48    "**/*.min.*",
 49]
 50
 51
 52class OxcTool:
 53    """Download, install, and invoke an Oxc CLI binary (oxlint or oxfmt)."""
 54
 55    def __init__(self, name: str) -> None:
 56        if name not in ("oxlint", "oxfmt"):
 57            raise ValueError(f"Unknown Oxc tool: {name}")
 58        self.name = name
 59
 60    def binary_path(self, version: str) -> Path:
 61        """Machine-level cache path for a specific Oxc version."""
 62        exe = ".exe" if platform.system() == "Windows" else ""
 63        return PLAIN_CACHE_PATH / "oxc" / version / f"{self.name}{exe}"
 64
 65    def is_installed(self) -> bool:
 66        version = self.get_version_from_config()
 67        return bool(version) and self.binary_path(version).exists()
 68
 69    @staticmethod
 70    def get_version_from_config() -> str:
 71        project_root = os.path.dirname(str(PLAIN_TEMP_PATH))
 72        pyproject = os.path.join(project_root, "pyproject.toml")
 73        if not os.path.exists(pyproject):
 74            return ""
 75        with open(pyproject, "rb") as f:
 76            doc = tomllib.load(f)
 77        return (
 78            doc.get("tool", {})
 79            .get("plain", {})
 80            .get("code", {})
 81            .get("oxc", {})
 82            .get("version", "")
 83        )
 84
 85    @staticmethod
 86    def set_version_in_config(version: str) -> None:
 87        project_root = os.path.dirname(str(PLAIN_TEMP_PATH))
 88        pyproject = os.path.join(project_root, "pyproject.toml")
 89        if not os.path.exists(pyproject):
 90            return
 91        with open(pyproject) as f:
 92            doc = tomlkit.load(f)
 93        doc.setdefault("tool", {}).setdefault("plain", {}).setdefault(
 94            "code", {}
 95        ).setdefault("oxc", {})["version"] = version
 96        with open(pyproject, "w") as f:
 97            tomlkit.dump(doc, f)
 98
 99    def detect_platform_slug(self) -> str:
100        system = platform.system()
101        arch = platform.machine()
102        if system == "Windows":
103            if arch.lower() in ("arm64", "aarch64"):
104                return "aarch64-pc-windows-msvc"
105            return "x86_64-pc-windows-msvc"
106        if system == "Linux":
107            if arch == "aarch64":
108                return "aarch64-unknown-linux-gnu"
109            return "x86_64-unknown-linux-gnu"
110        if system == "Darwin":
111            if arch == "arm64":
112                return "aarch64-apple-darwin"
113            return "x86_64-apple-darwin"
114        raise RuntimeError(f"Unsupported platform for Oxc: {system}/{arch}")
115
116    @staticmethod
117    def get_latest_version() -> str:
118        """Find the latest apps_v release tag via the GitHub API."""
119        try:
120            resp = httpx.get(
121                "https://api.github.com/repos/oxc-project/oxc/releases",
122                params={"per_page": 20},
123                headers={"Accept": "application/vnd.github+json"},
124                follow_redirects=True,
125            )
126            resp.raise_for_status()
127        except httpx.HTTPError as e:
128            raise click.ClickException(
129                "Couldn't reach github.com to look up the latest oxlint/oxfmt "
130                f"release ({e}). If this environment can't reach GitHub, pin a "
131                "version in pyproject.toml under [tool.plain.code.oxc], or pass "
132                "--skip-oxc."
133            ) from e
134        for release in resp.json():
135            tag = release["tag_name"]
136            if tag.startswith(TAG_PREFIX):
137                return tag[len(TAG_PREFIX) :]
138        raise RuntimeError("No apps_v release found on GitHub")
139
140    def download(self, version: str = "") -> str:
141        if not version:
142            version = self.get_latest_version()
143
144        slug = self.detect_platform_slug()
145        is_windows = platform.system() == "Windows"
146        ext = "zip" if is_windows else "tar.gz"
147        asset = f"{self.name}-{slug}.{ext}"
148        url = f"https://github.com/oxc-project/oxc/releases/download/{TAG_PREFIX}{version}/{asset}"
149
150        # Download into memory for extraction
151        data = io.BytesIO()
152        try:
153            with httpx.stream("GET", url, follow_redirects=True) as resp:
154                resp.raise_for_status()
155                total = int(resp.headers.get("Content-Length", 0))
156                if total:
157                    with click.progressbar(
158                        length=total,
159                        label=f"Downloading {self.name}",
160                        width=0,
161                    ) as bar:
162                        for chunk in resp.iter_bytes(chunk_size=1024 * 1024):
163                            data.write(chunk)
164                            bar.update(len(chunk))
165                else:
166                    for chunk in resp.iter_bytes(chunk_size=1024 * 1024):
167                        data.write(chunk)
168        except httpx.HTTPError as e:
169            raise click.ClickException(
170                f"Couldn't download {self.name} from github.com ({e}). If this "
171                "environment can't reach GitHub, pass --skip-oxc."
172            ) from e
173
174        data.seek(0)
175
176        # Extract to a temp file first, then atomically move it into the
177        # versioned cache path (parallel checkouts can download concurrently).
178        resolved = version.lstrip("v")
179        binary_path = self.binary_path(resolved)
180        binary_path.parent.mkdir(parents=True, exist_ok=True)
181        tmp_path = binary_path.parent / f".{self.name}-download-{os.getpid()}"
182
183        try:
184            if is_windows:
185                with zipfile.ZipFile(data) as zf:
186                    # Find the binary inside the archive
187                    members = zf.namelist()
188                    binary_name = next(m for m in members if m.startswith(self.name))
189                    with (
190                        zf.open(binary_name) as src,
191                        open(tmp_path, "wb") as dst,
192                    ):
193                        dst.write(src.read())
194            else:
195                with tarfile.open(fileobj=data, mode="r:gz") as tf:
196                    members = tf.getnames()
197                    binary_name = next(m for m in members if m.startswith(self.name))
198                    extracted = tf.extractfile(binary_name)
199                    if extracted is None:
200                        raise RuntimeError(
201                            f"Failed to extract {binary_name} from archive"
202                        )
203                    with open(tmp_path, "wb") as dst:
204                        dst.write(extracted.read())
205
206            os.chmod(tmp_path, 0o755)
207            try:
208                os.replace(tmp_path, binary_path)
209            except PermissionError:
210                # Windows raises this if another process has binary_path open
211                # (e.g. another checkout sharing this machine-wide cache is
212                # currently running it). If it's already there, another
213                # process finished installing this exact version — nothing
214                # left to do.
215                if not binary_path.exists():
216                    raise
217        finally:
218            tmp_path.unlink(missing_ok=True)
219
220        return resolved
221
222    def invoke(self, *args: str, cwd: str | None = None) -> subprocess.CompletedProcess:
223        version = self.get_version_from_config()
224        if not version:
225            raise RuntimeError(
226                "No Oxc version configured in pyproject.toml — run `plain code install`"
227            )
228        check_min_version(version)
229        if self.name == "oxlint":
230            # oxlint takes ignores as repeated --ignore-pattern flags.
231            ignore_args = []
232            for pattern in IGNORE_PATTERNS:
233                ignore_args += ["--ignore-pattern", pattern]
234        else:
235            # oxfmt has no --ignore-pattern; it excludes via `!`-prefixed paths.
236            ignore_args = [f"!{pattern}" for pattern in IGNORE_PATTERNS]
237        result = subprocess.run(
238            [
239                self.binary_path(version),
240                *args,
241                # Both tools exit non-zero when the paths match no files, which
242                # is the normal state for a project with no JS/TS in it.
243                "--no-error-on-unmatched-pattern",
244                *ignore_args,
245            ],
246            cwd=cwd,
247            capture_output=True,
248            text=True,
249            check=False,
250        )
251        if result.stdout:
252            print(result.stdout, end="")
253        if result.stderr:
254            # We deliberately don't pass a config file, so drop oxfmt's nudge to
255            # add one — projects can still add their own `.oxfmtrc.json`.
256            stderr = "".join(
257                line
258                for line in result.stderr.splitlines(keepends=True)
259                if "No config found, using defaults" not in line
260            )
261            if stderr:
262                print(stderr, end="", file=sys.stderr)
263        return result
264
265
266def install_oxc(version: str = "") -> str:
267    """Install both oxlint and oxfmt, return the resolved version."""
268    if version:
269        # Before downloading, so a pin below the minimum is rejected instead of
270        # installing, reporting success, and then failing on first use — which
271        # `plain fix` would repeat on every run, since installing "worked".
272        check_min_version(version)
273
274    oxlint = OxcTool("oxlint")
275    oxfmt = OxcTool("oxfmt")
276
277    resolved = oxlint.download(version)
278    oxfmt.download(resolved)
279
280    OxcTool.set_version_in_config(resolved)
281    return resolved