maina 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- maina/__init__.py +76 -0
- maina/backends/__init__.py +11 -0
- maina/backends/archive.py +163 -0
- maina/backends/github.py +113 -0
- maina/backends/npm.py +186 -0
- maina/backends/path.py +49 -0
- maina/backends/pip.py +206 -0
- maina/backends/script.py +110 -0
- maina/backends/unpack.py +302 -0
- maina/catalog_validate.py +110 -0
- maina/cli.py +498 -0
- maina/clitokens.py +336 -0
- maina/core/__init__.py +3 -0
- maina/core/argparser.py +340 -0
- maina/core/envfilter.py +63 -0
- maina/core/errors.py +127 -0
- maina/core/parsers.py +275 -0
- maina/core/platform.py +74 -0
- maina/core/procgroup.py +87 -0
- maina/core/protocols.py +113 -0
- maina/core/registry/__init__.py +52 -0
- maina/core/registry/common.py +138 -0
- maina/core/registry/ids.py +124 -0
- maina/core/registry/install.py +289 -0
- maina/core/registry/jsonschema.py +167 -0
- maina/core/registry/keys.py +101 -0
- maina/core/registry/loader.py +485 -0
- maina/core/registry/merge.py +99 -0
- maina/core/registry/requires.py +132 -0
- maina/core/registry/specs.py +437 -0
- maina/core/registry/validate.py +165 -0
- maina/core/reportfile.py +106 -0
- maina/core/result.py +80 -0
- maina/core/subprocess_utils.py +165 -0
- maina/installer.py +465 -0
- maina/mise.py +233 -0
- maina/py.typed +0 -0
- maina/runner.py +365 -0
- maina/runtime.py +388 -0
- maina/tool.py +368 -0
- maina-0.1.0.dist-info/METADATA +129 -0
- maina-0.1.0.dist-info/RECORD +45 -0
- maina-0.1.0.dist-info/WHEEL +4 -0
- maina-0.1.0.dist-info/entry_points.txt +3 -0
- maina-0.1.0.dist-info/licenses/LICENSE +21 -0
maina/__init__.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Call non-Python CLIs from Python via a registry-first Tool runtime."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
|
|
6
|
+
from importlib.metadata import version as _version
|
|
7
|
+
|
|
8
|
+
from maina.core.errors import (
|
|
9
|
+
AbstractToolError,
|
|
10
|
+
ArgError,
|
|
11
|
+
InstallError,
|
|
12
|
+
MainaError,
|
|
13
|
+
ManifestError,
|
|
14
|
+
ParseError,
|
|
15
|
+
RegisterError,
|
|
16
|
+
RuntimeProvisionError,
|
|
17
|
+
ToolExecError,
|
|
18
|
+
ToolExitError,
|
|
19
|
+
ToolOutputError,
|
|
20
|
+
ToolTimeoutError,
|
|
21
|
+
UnsupportedPlatformError,
|
|
22
|
+
)
|
|
23
|
+
from maina.core.parsers import FunctionParser, parser
|
|
24
|
+
from maina.core.protocols import (
|
|
25
|
+
InstallContext,
|
|
26
|
+
InstallerBackend,
|
|
27
|
+
LanguageRuntimeProvider,
|
|
28
|
+
Parser,
|
|
29
|
+
)
|
|
30
|
+
from maina.core.registry import ArgSpec, InstallSpec, Manifest, OutputSpec, Registry
|
|
31
|
+
from maina.core.result import ToolResult
|
|
32
|
+
from maina.runner import ToolRunner
|
|
33
|
+
from maina.runtime import Runtime, isolated, runtime, use
|
|
34
|
+
from maina.tool import MainaApp, Tool, register
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
__version__ = _version("maina")
|
|
38
|
+
except _PackageNotFoundError: # pragma: no cover - source checkout without install
|
|
39
|
+
__version__ = "0.0.0+unknown"
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"AbstractToolError",
|
|
43
|
+
"ArgError",
|
|
44
|
+
"ArgSpec",
|
|
45
|
+
"FunctionParser",
|
|
46
|
+
"InstallContext",
|
|
47
|
+
"InstallError",
|
|
48
|
+
"InstallSpec",
|
|
49
|
+
"InstallerBackend",
|
|
50
|
+
"LanguageRuntimeProvider",
|
|
51
|
+
"MainaApp",
|
|
52
|
+
"MainaError",
|
|
53
|
+
"Manifest",
|
|
54
|
+
"ManifestError",
|
|
55
|
+
"OutputSpec",
|
|
56
|
+
"ParseError",
|
|
57
|
+
"Parser",
|
|
58
|
+
"RegisterError",
|
|
59
|
+
"Registry",
|
|
60
|
+
"Runtime",
|
|
61
|
+
"RuntimeProvisionError",
|
|
62
|
+
"Tool",
|
|
63
|
+
"ToolExecError",
|
|
64
|
+
"ToolExitError",
|
|
65
|
+
"ToolOutputError",
|
|
66
|
+
"ToolResult",
|
|
67
|
+
"ToolRunner",
|
|
68
|
+
"ToolTimeoutError",
|
|
69
|
+
"UnsupportedPlatformError",
|
|
70
|
+
"__version__",
|
|
71
|
+
"isolated",
|
|
72
|
+
"parser",
|
|
73
|
+
"register",
|
|
74
|
+
"runtime",
|
|
75
|
+
"use",
|
|
76
|
+
]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Shipped installer backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from maina.backends.github import GithubBackend
|
|
6
|
+
from maina.backends.npm import NpmBackend
|
|
7
|
+
from maina.backends.path import PathBackend
|
|
8
|
+
from maina.backends.pip import PipBackend
|
|
9
|
+
from maina.backends.script import ScriptBackend
|
|
10
|
+
|
|
11
|
+
__all__ = ["GithubBackend", "NpmBackend", "PathBackend", "PipBackend", "ScriptBackend"]
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Shared archive download/extract helpers for installer backends."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import tempfile
|
|
7
|
+
import time
|
|
8
|
+
from contextlib import suppress
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
from urllib.parse import urljoin, urlparse
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
|
|
15
|
+
from maina.backends.unpack import UNPACK_ERRORS, ArchiveUnpacker
|
|
16
|
+
from maina.core.errors import InstallError
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from collections.abc import Iterable
|
|
20
|
+
|
|
21
|
+
# Soft cap for streamed release assets (GitHub binaries, etc.).
|
|
22
|
+
DEFAULT_MAX_BYTES = 512 * 1024 * 1024
|
|
23
|
+
CHUNK_SIZE = 64 * 1024
|
|
24
|
+
# Wall-clock cap on one download; the per-read timeout alone lets a slow-drip server hold it open.
|
|
25
|
+
DOWNLOAD_MAX_SECONDS = 30 * 60
|
|
26
|
+
REDIRECT_STATUSES = frozenset({301, 302, 303, 307, 308})
|
|
27
|
+
MAX_REDIRECT_HOPS = 5
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ArchiveFetcher(ArchiveUnpacker):
|
|
31
|
+
"""Download https assets and extract binaries into a destination directory.
|
|
32
|
+
|
|
33
|
+
When ``allowed_redirect_hosts`` is given, redirects are resolved hop by hop
|
|
34
|
+
and every hop host must match one of those names (exact or ``.suffix``);
|
|
35
|
+
this keeps a github download from being bounced to an arbitrary host. With
|
|
36
|
+
``None`` (default) redirects are followed normally.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(
|
|
40
|
+
self,
|
|
41
|
+
*,
|
|
42
|
+
session: requests.Session | None = None,
|
|
43
|
+
max_bytes: int = DEFAULT_MAX_BYTES,
|
|
44
|
+
allowed_redirect_hosts: Iterable[str] | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._session = session or requests.Session()
|
|
47
|
+
self.max_bytes = max_bytes
|
|
48
|
+
self._allowed_hosts = (
|
|
49
|
+
None if allowed_redirect_hosts is None else frozenset(allowed_redirect_hosts)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def close(self) -> None:
|
|
53
|
+
"""Close the underlying :class:`requests.Session`."""
|
|
54
|
+
self._session.close()
|
|
55
|
+
|
|
56
|
+
def download_and_extract(
|
|
57
|
+
self,
|
|
58
|
+
url: str,
|
|
59
|
+
dest_dir: Path,
|
|
60
|
+
*,
|
|
61
|
+
binary: str,
|
|
62
|
+
asset_name: str,
|
|
63
|
+
sha256: str | None = None,
|
|
64
|
+
allow_any_executable: bool = False,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""Download an asset and extract/copy the binary into dest_dir.
|
|
67
|
+
|
|
68
|
+
When *sha256* is given, the downloaded asset must match that hex digest
|
|
69
|
+
before it is extracted; a mismatch raises :class:`InstallError`.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
74
|
+
tmp_path = Path(tmp)
|
|
75
|
+
# asset_name is validated at manifest load, but never let it escape.
|
|
76
|
+
archive = tmp_path / Path(asset_name).name
|
|
77
|
+
self.fetch_https(url, archive)
|
|
78
|
+
if sha256:
|
|
79
|
+
self._verify_sha256(archive, expected=sha256, url=url)
|
|
80
|
+
self.extract_archive(
|
|
81
|
+
archive,
|
|
82
|
+
tmp_path,
|
|
83
|
+
dest_dir,
|
|
84
|
+
binary=binary,
|
|
85
|
+
asset_name=asset_name,
|
|
86
|
+
allow_any_executable=allow_any_executable,
|
|
87
|
+
)
|
|
88
|
+
except UNPACK_ERRORS as exc:
|
|
89
|
+
raise InstallError(f"could not install {asset_name} from {url}: {exc}") from exc
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def _verify_sha256(archive: Path, *, expected: str, url: str) -> None:
|
|
93
|
+
"""Raise unless *archive* hashes to *expected* (case-insensitive hex)."""
|
|
94
|
+
with archive.open("rb") as handle:
|
|
95
|
+
actual = hashlib.file_digest(handle, "sha256").hexdigest()
|
|
96
|
+
if actual.lower() != expected.strip().lower():
|
|
97
|
+
raise InstallError(
|
|
98
|
+
f"sha256 mismatch for {url}: expected {expected.strip().lower()}, got {actual}"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def fetch_https(self, url: str, dest: Path) -> None:
|
|
102
|
+
"""Stream a URL that must use the https scheme, enforcing a size cap."""
|
|
103
|
+
if urlparse(url).scheme != "https" or not urlparse(url).netloc:
|
|
104
|
+
raise InstallError(f"refusing non-https download URL: {url}")
|
|
105
|
+
if self._allowed_hosts is None:
|
|
106
|
+
self._stream_get(url, dest, allow_redirects=True)
|
|
107
|
+
return
|
|
108
|
+
current = url
|
|
109
|
+
for _ in range(MAX_REDIRECT_HOPS):
|
|
110
|
+
self._require_allowed_host(current)
|
|
111
|
+
location = self._stream_get(current, dest, allow_redirects=False)
|
|
112
|
+
if location is None:
|
|
113
|
+
return
|
|
114
|
+
current = urljoin(current, location)
|
|
115
|
+
raise InstallError(f"too many redirects fetching {url}")
|
|
116
|
+
|
|
117
|
+
def _stream_get(self, url: str, dest: Path, *, allow_redirects: bool) -> str | None:
|
|
118
|
+
"""GET *url*; write the body, or return a redirect Location to follow."""
|
|
119
|
+
try:
|
|
120
|
+
with self._session.get(
|
|
121
|
+
url, timeout=120, stream=True, allow_redirects=allow_redirects
|
|
122
|
+
) as response:
|
|
123
|
+
if not allow_redirects and response.status_code in REDIRECT_STATUSES:
|
|
124
|
+
location = response.headers.get("Location")
|
|
125
|
+
if not location:
|
|
126
|
+
raise InstallError(f"redirect missing Location: {url}")
|
|
127
|
+
return location
|
|
128
|
+
response.raise_for_status()
|
|
129
|
+
self._write_capped_stream(response, dest, url=url)
|
|
130
|
+
except requests.RequestException as exc:
|
|
131
|
+
raise InstallError(f"download failed for {url}: {exc}") from exc
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
def _require_allowed_host(self, url: str) -> None:
|
|
135
|
+
parsed = urlparse(url)
|
|
136
|
+
host = (parsed.hostname or "").lower()
|
|
137
|
+
listed = self._host_in_allowlist(host)
|
|
138
|
+
if parsed.scheme != "https" or parsed.username is not None or not host or not listed:
|
|
139
|
+
raise InstallError(f"refusing redirect off allowed hosts: {url}")
|
|
140
|
+
|
|
141
|
+
def _host_in_allowlist(self, host: str) -> bool:
|
|
142
|
+
allowed = self._allowed_hosts or frozenset()
|
|
143
|
+
return any(host == name or host.endswith(f".{name}") for name in allowed)
|
|
144
|
+
|
|
145
|
+
def _write_capped_stream(self, response: requests.Response, dest: Path, *, url: str) -> None:
|
|
146
|
+
"""Write response body to dest, rejecting Content-Length or body over max_bytes."""
|
|
147
|
+
content_length = response.headers.get("Content-Length")
|
|
148
|
+
if content_length is not None:
|
|
149
|
+
with suppress(ValueError):
|
|
150
|
+
if int(content_length) > self.max_bytes:
|
|
151
|
+
raise InstallError(f"download exceeds size cap ({self.max_bytes} bytes): {url}")
|
|
152
|
+
total = 0
|
|
153
|
+
deadline = time.monotonic() + DOWNLOAD_MAX_SECONDS
|
|
154
|
+
with dest.open("wb") as out:
|
|
155
|
+
for chunk in response.iter_content(chunk_size=CHUNK_SIZE):
|
|
156
|
+
if not chunk:
|
|
157
|
+
continue
|
|
158
|
+
if time.monotonic() > deadline:
|
|
159
|
+
raise InstallError(f"download took over {DOWNLOAD_MAX_SECONDS}s: {url}")
|
|
160
|
+
total += len(chunk)
|
|
161
|
+
if total > self.max_bytes:
|
|
162
|
+
raise InstallError(f"download exceeds size cap ({self.max_bytes} bytes): {url}")
|
|
163
|
+
out.write(chunk)
|
maina/backends/github.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""GitHub release asset installer backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
from urllib.parse import quote
|
|
7
|
+
|
|
8
|
+
from maina.backends.archive import ArchiveFetcher
|
|
9
|
+
from maina.core.errors import InstallError
|
|
10
|
+
from maina.core.platform import platform_key
|
|
11
|
+
from maina.core.protocols import InstallerBackend
|
|
12
|
+
from maina.core.registry.common import is_unsafe_segment
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from maina.core.protocols import InstallContext
|
|
18
|
+
from maina.core.registry import Manifest
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
GITHUB_DOWNLOAD_HOSTS = frozenset(
|
|
22
|
+
{"github.com", "api.github.com", "githubusercontent.com", "codeload.github.com"}
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class GithubBackend(InstallerBackend):
|
|
27
|
+
"""Download a GitHub release asset into the tool cache (``backend: github``)."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, fetcher: ArchiveFetcher | None = None) -> None:
|
|
30
|
+
self._fetcher = fetcher or ArchiveFetcher(allowed_redirect_hosts=GITHUB_DOWNLOAD_HOSTS)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def name(self) -> str:
|
|
34
|
+
return "github"
|
|
35
|
+
|
|
36
|
+
def close(self) -> None:
|
|
37
|
+
"""Close the shared HTTP session."""
|
|
38
|
+
self._fetcher.close()
|
|
39
|
+
|
|
40
|
+
def resolve(
|
|
41
|
+
self,
|
|
42
|
+
manifest: Manifest,
|
|
43
|
+
*,
|
|
44
|
+
version: str,
|
|
45
|
+
env: dict[str, str],
|
|
46
|
+
ctx: InstallContext,
|
|
47
|
+
) -> tuple[str, dict[str, str]]:
|
|
48
|
+
exe = self._resolve_github(manifest, version, ctx)
|
|
49
|
+
return str(exe), ctx.prepend_path(env, exe.parent)
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def probe(manifest: Manifest, *, version: str, ctx: InstallContext) -> bool:
|
|
53
|
+
"""True when the cached release binary already exists (no download)."""
|
|
54
|
+
binary = manifest.install.binary or manifest.cli
|
|
55
|
+
cached = ctx.cache_root / "tools" / manifest.cli / version / "bin" / binary
|
|
56
|
+
return cached.is_file()
|
|
57
|
+
|
|
58
|
+
def _resolve_github(self, manifest: Manifest, version: str, ctx: InstallContext) -> Path:
|
|
59
|
+
"""Download a GitHub release asset into the tool cache if needed."""
|
|
60
|
+
repo, binary = self._install_parts(manifest)
|
|
61
|
+
key = platform_key()
|
|
62
|
+
asset = manifest.install.asset_for(key, manifest.version)
|
|
63
|
+
if not asset:
|
|
64
|
+
raise InstallError(f"{manifest.id}: no github asset for {key}")
|
|
65
|
+
if is_unsafe_segment(asset):
|
|
66
|
+
raise InstallError(
|
|
67
|
+
f"{manifest.id}: asset name {asset!r} for {key} is not a safe path segment "
|
|
68
|
+
"(check install.asset_template and os_map / arch_map)"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
dest_dir = ctx.ensure_tool_bin_dir(manifest.cli, version)
|
|
72
|
+
dest = dest_dir / binary
|
|
73
|
+
if dest.is_file():
|
|
74
|
+
return dest
|
|
75
|
+
|
|
76
|
+
# ``version`` is the identity-keyed cache segment; the release tag is the
|
|
77
|
+
# manifest's own version (or an explicit ``install.tag``).
|
|
78
|
+
tag = manifest.install.tag or manifest.version
|
|
79
|
+
url = self._release_url(repo, str(asset), tag=str(tag) if tag else None)
|
|
80
|
+
self._fetcher.download_and_extract(
|
|
81
|
+
url,
|
|
82
|
+
dest_dir,
|
|
83
|
+
binary=binary,
|
|
84
|
+
asset_name=str(asset),
|
|
85
|
+
sha256=manifest.install.sha256.get(key),
|
|
86
|
+
allow_any_executable=manifest.install.allow_any_executable,
|
|
87
|
+
)
|
|
88
|
+
if not dest.is_file():
|
|
89
|
+
raise InstallError(
|
|
90
|
+
f"{manifest.id}: downloaded asset but {binary} missing in {dest_dir}"
|
|
91
|
+
)
|
|
92
|
+
return dest
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def _install_parts(manifest: Manifest) -> tuple[str, str]:
|
|
96
|
+
"""Validate and return repo and binary name."""
|
|
97
|
+
install = manifest.install
|
|
98
|
+
repo = install.repo
|
|
99
|
+
binary = install.binary or manifest.cli
|
|
100
|
+
if not repo or not install.has_assets():
|
|
101
|
+
raise InstallError(
|
|
102
|
+
f"{manifest.id}: github install needs repo and assets (or asset_template)"
|
|
103
|
+
)
|
|
104
|
+
return repo, binary
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def _release_url(repo: str, asset: str, *, tag: str | None) -> str:
|
|
108
|
+
"""Build a GitHub release download URL."""
|
|
109
|
+
asset_quoted = quote(asset, safe="")
|
|
110
|
+
tag_quoted = quote(tag or "", safe="")
|
|
111
|
+
latest = f"https://github.com/{repo}/releases/latest/download/{asset_quoted}"
|
|
112
|
+
pinned = f"https://github.com/{repo}/releases/download/{tag_quoted}/{asset_quoted}"
|
|
113
|
+
return pinned if tag and tag != "latest" else latest
|
maina/backends/npm.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""npm package installer backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import shutil
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from maina.core.errors import InstallError
|
|
10
|
+
from maina.core.protocols import InstallerBackend
|
|
11
|
+
from maina.core.subprocess_utils import (
|
|
12
|
+
INSTALL_OUTPUT_MAX_BYTES,
|
|
13
|
+
resolve_within,
|
|
14
|
+
run_or_wrap_capped,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from maina.core.protocols import InstallContext
|
|
21
|
+
from maina.core.registry import Manifest
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class NpmBackend(InstallerBackend):
|
|
25
|
+
"""Install an npm package into the tool cache (``install.backend: npm``)."""
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def name(self) -> str:
|
|
29
|
+
return "npm"
|
|
30
|
+
|
|
31
|
+
def resolve(
|
|
32
|
+
self,
|
|
33
|
+
manifest: Manifest,
|
|
34
|
+
*,
|
|
35
|
+
version: str,
|
|
36
|
+
env: dict[str, str],
|
|
37
|
+
ctx: InstallContext,
|
|
38
|
+
) -> tuple[str, dict[str, str]]:
|
|
39
|
+
exe = self._resolve_npm(manifest, version, env, ctx)
|
|
40
|
+
return str(exe), ctx.prepend_path(env, exe.parent)
|
|
41
|
+
|
|
42
|
+
@staticmethod
|
|
43
|
+
def probe(manifest: Manifest, *, version: str, ctx: InstallContext) -> bool:
|
|
44
|
+
"""True when the npm bin link already exists in the cache (no install)."""
|
|
45
|
+
link = (
|
|
46
|
+
ctx.cache_root
|
|
47
|
+
/ "tools"
|
|
48
|
+
/ manifest.cli
|
|
49
|
+
/ version
|
|
50
|
+
/ "npm"
|
|
51
|
+
/ "node_modules"
|
|
52
|
+
/ ".bin"
|
|
53
|
+
/ manifest.cli
|
|
54
|
+
)
|
|
55
|
+
return link.exists()
|
|
56
|
+
|
|
57
|
+
def _resolve_npm(
|
|
58
|
+
self,
|
|
59
|
+
manifest: Manifest,
|
|
60
|
+
version: str,
|
|
61
|
+
env: dict[str, str],
|
|
62
|
+
ctx: InstallContext,
|
|
63
|
+
) -> Path:
|
|
64
|
+
"""Install an npm package into the tool cache and return its bin path.
|
|
65
|
+
|
|
66
|
+
*version* is the identity-keyed cache-dir segment; the npm dist-tag /
|
|
67
|
+
version comes from ``manifest.version``.
|
|
68
|
+
"""
|
|
69
|
+
package = manifest.install.package or manifest.cli
|
|
70
|
+
self._ensure_node_on_path(manifest, env, ctx)
|
|
71
|
+
prefix = ctx.cache_root / "tools" / manifest.cli / version / "npm"
|
|
72
|
+
link = prefix / "node_modules" / ".bin" / manifest.cli
|
|
73
|
+
return (
|
|
74
|
+
link
|
|
75
|
+
if link.exists()
|
|
76
|
+
else self._install_package(
|
|
77
|
+
manifest.cli,
|
|
78
|
+
package,
|
|
79
|
+
manifest.version,
|
|
80
|
+
prefix,
|
|
81
|
+
link,
|
|
82
|
+
env,
|
|
83
|
+
allow_scripts=manifest.install.allow_scripts,
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
@staticmethod
|
|
88
|
+
def _ensure_node_on_path(
|
|
89
|
+
manifest: Manifest,
|
|
90
|
+
env: dict[str, str],
|
|
91
|
+
ctx: InstallContext,
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Ensure Node is on *env* PATH without clobbering composed bins.
|
|
94
|
+
|
|
95
|
+
``LanguageRuntimeProvider.env`` typically rebuilds PATH from
|
|
96
|
+
``os.environ``, which would drop bins prepended by
|
|
97
|
+
``Installer._compose_requires_env``. Prefer ``ensure`` +
|
|
98
|
+
:meth:`InstallContext.prepend_path` on the existing env.
|
|
99
|
+
"""
|
|
100
|
+
node_version = manifest.requires.get("node") or "latest"
|
|
101
|
+
if shutil.which("node", path=env.get("PATH")):
|
|
102
|
+
return
|
|
103
|
+
# Hand mise the trimmed install env so its subprocesses honour env_allow.
|
|
104
|
+
bin_dir = ctx.language.ensure("node", node_version, base_env=env)
|
|
105
|
+
ctx.prepend_path(env, bin_dir)
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def _install_package(
|
|
109
|
+
cli: str,
|
|
110
|
+
package: str,
|
|
111
|
+
version: str | None,
|
|
112
|
+
prefix: Path,
|
|
113
|
+
link: Path,
|
|
114
|
+
env: dict[str, str],
|
|
115
|
+
*,
|
|
116
|
+
allow_scripts: bool = False,
|
|
117
|
+
) -> Path:
|
|
118
|
+
"""npm install a package into prefix and return its bin link."""
|
|
119
|
+
prefix.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
# Anchor the local project so npm does not walk up to a parent
|
|
121
|
+
# package.json (or a global root) and resolve the bin to an outside
|
|
122
|
+
# install.
|
|
123
|
+
anchor = prefix / "package.json"
|
|
124
|
+
if not anchor.exists():
|
|
125
|
+
anchor.write_text(
|
|
126
|
+
json.dumps({"name": f"maina-tool-{cli}", "private": True}) + "\n",
|
|
127
|
+
encoding="utf-8",
|
|
128
|
+
)
|
|
129
|
+
pkg_spec = f"{package}@{version}" if version and version != "default" else package
|
|
130
|
+
npm = shutil.which("npm", path=env.get("PATH"))
|
|
131
|
+
if not npm:
|
|
132
|
+
raise InstallError("npm not found; ensure node runtime via mise")
|
|
133
|
+
NpmBackend._npm_install(npm, prefix, pkg_spec, env, allow_scripts=allow_scripts)
|
|
134
|
+
if not (link.is_file() or link.is_symlink()):
|
|
135
|
+
raise InstallError(f"npm install of {pkg_spec} did not produce {cli}")
|
|
136
|
+
resolve_within(
|
|
137
|
+
link,
|
|
138
|
+
prefix,
|
|
139
|
+
error_cls=InstallError,
|
|
140
|
+
message=(
|
|
141
|
+
f"npm resolved {cli} to {link.resolve()}, outside the tool cache "
|
|
142
|
+
f"({prefix}); refusing to link an out-of-cache install"
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
return link
|
|
146
|
+
|
|
147
|
+
INSTALL_TIMEOUT = 600
|
|
148
|
+
|
|
149
|
+
@staticmethod
|
|
150
|
+
def _npm_install(
|
|
151
|
+
npm: str,
|
|
152
|
+
prefix: Path,
|
|
153
|
+
pkg_spec: str,
|
|
154
|
+
env: dict[str, str],
|
|
155
|
+
*,
|
|
156
|
+
allow_scripts: bool = False,
|
|
157
|
+
) -> None:
|
|
158
|
+
"""Run npm install --prefix for a package (local, no lockfile churn).
|
|
159
|
+
|
|
160
|
+
``--ignore-scripts`` by default: a third-party package's install /
|
|
161
|
+
postinstall lifecycle scripts run with the composed environment, so a
|
|
162
|
+
manifest must opt in with ``install.allow_scripts: true``.
|
|
163
|
+
"""
|
|
164
|
+
argv = [
|
|
165
|
+
npm,
|
|
166
|
+
"install",
|
|
167
|
+
"--prefix",
|
|
168
|
+
str(prefix),
|
|
169
|
+
"--no-fund",
|
|
170
|
+
"--no-audit",
|
|
171
|
+
"--no-package-lock",
|
|
172
|
+
]
|
|
173
|
+
if not allow_scripts:
|
|
174
|
+
argv.append("--ignore-scripts")
|
|
175
|
+
argv.extend(["--", pkg_spec])
|
|
176
|
+
run_or_wrap_capped(
|
|
177
|
+
argv,
|
|
178
|
+
env=env,
|
|
179
|
+
timeout=NpmBackend.INSTALL_TIMEOUT,
|
|
180
|
+
max_bytes=INSTALL_OUTPUT_MAX_BYTES,
|
|
181
|
+
error_cls=InstallError,
|
|
182
|
+
on_timeout=(
|
|
183
|
+
lambda: f"npm install {pkg_spec} timed out after {NpmBackend.INSTALL_TIMEOUT}s"
|
|
184
|
+
),
|
|
185
|
+
on_failure=lambda detail: f"npm install {pkg_spec} failed: {detail}",
|
|
186
|
+
)
|
maina/backends/path.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""PATH-backed installer backend."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from maina.core.errors import InstallError
|
|
9
|
+
from maina.core.protocols import InstallerBackend
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from maina.core.protocols import InstallContext
|
|
13
|
+
from maina.core.registry import Manifest
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class PathBackend(InstallerBackend):
|
|
17
|
+
"""Resolve a CLI from PATH (``install.backend: path``)."""
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def name(self) -> str:
|
|
21
|
+
return "path"
|
|
22
|
+
|
|
23
|
+
def resolve( # ruff:ignore[no-self-use] - must be an instance method, overrides InstallerBackend.resolve
|
|
24
|
+
self,
|
|
25
|
+
manifest: Manifest,
|
|
26
|
+
*,
|
|
27
|
+
version: str,
|
|
28
|
+
env: dict[str, str],
|
|
29
|
+
ctx: InstallContext,
|
|
30
|
+
) -> tuple[str, dict[str, str]]:
|
|
31
|
+
del version, ctx
|
|
32
|
+
found = shutil.which(manifest.cli, path=env.get("PATH"))
|
|
33
|
+
if not found:
|
|
34
|
+
raise InstallError(
|
|
35
|
+
f"{manifest.cli!r} not found on PATH — install it, or add an install: block "
|
|
36
|
+
"(github, npm, pip, script) so maina can fetch it"
|
|
37
|
+
)
|
|
38
|
+
return found, env
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def probe(manifest: Manifest, *, version: str, ctx: InstallContext) -> bool:
|
|
42
|
+
"""True when the CLI already resolves on PATH (no side effects).
|
|
43
|
+
|
|
44
|
+
Searches ``ctx.probe_path`` — the ambient PATH plus the bin dirs of any
|
|
45
|
+
already-installed ``requires:`` runtimes — so a tool provided by a pinned
|
|
46
|
+
runtime is not reported ``missing`` by a check that ``resolve`` passes.
|
|
47
|
+
"""
|
|
48
|
+
del version
|
|
49
|
+
return shutil.which(manifest.cli, path=ctx.probe_path) is not None
|