sdkmgr 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.
- sdkmgr/__init__.py +8 -0
- sdkmgr/__main__.py +3 -0
- sdkmgr/android.py +290 -0
- sdkmgr/bootstrap.py +79 -0
- sdkmgr/cli.py +63 -0
- sdkmgr/core.py +103 -0
- sdkmgr/download.py +59 -0
- sdkmgr/errors.py +10 -0
- sdkmgr/installation.py +58 -0
- sdkmgr/java.py +128 -0
- sdkmgr/mise.py +77 -0
- sdkmgr/nvm.py +148 -0
- sdkmgr/providers.py +43 -0
- sdkmgr/system.py +52 -0
- sdkmgr-0.1.0.dist-info/METADATA +382 -0
- sdkmgr-0.1.0.dist-info/RECORD +19 -0
- sdkmgr-0.1.0.dist-info/WHEEL +4 -0
- sdkmgr-0.1.0.dist-info/entry_points.txt +2 -0
- sdkmgr-0.1.0.dist-info/licenses/LICENSE +28 -0
sdkmgr/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Discover and provision Node, Java, and Android development tools."""
|
|
2
|
+
|
|
3
|
+
from .android import AndroidInstallation
|
|
4
|
+
from .core import Installation, Manager
|
|
5
|
+
from .errors import InstallationRequired, SDKError
|
|
6
|
+
|
|
7
|
+
__all__ = ["AndroidInstallation", "Installation", "InstallationRequired", "Manager", "SDKError"]
|
|
8
|
+
__version__ = "0.1.0"
|
sdkmgr/__main__.py
ADDED
sdkmgr/android.py
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
import stat
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import xml.etree.ElementTree as ET
|
|
10
|
+
import zipfile
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path, PurePosixPath
|
|
13
|
+
|
|
14
|
+
from filelock import FileLock, Timeout
|
|
15
|
+
|
|
16
|
+
from .download import fetch
|
|
17
|
+
from .errors import InstallationRequired, SDKError
|
|
18
|
+
from .providers import Platform
|
|
19
|
+
from .system import display_command
|
|
20
|
+
|
|
21
|
+
TOOLS_BUILD = "15859902"
|
|
22
|
+
# SHA-256 values published at https://developer.android.com/studio.
|
|
23
|
+
TOOLS_ARCHIVES = {
|
|
24
|
+
"linux-x64": ("linux", "4e4c464f145a7512b57d088ac6c278c03c9eea610886b35a5e0804e74eedf583"),
|
|
25
|
+
"macos-x64": ("mac_x86_64", "c5a6378ab5cf7e0d5701921405115befff13e9ff7417fb588389338f8bd050f3"),
|
|
26
|
+
"macos-arm64": ("mac_arm64", "835b62a26162b229b441d1f6d4680383815a270809eb33522c0d480fa5002c4e"),
|
|
27
|
+
"windows-x64": ("win", "90ae805d20434428bffcb699c290860f19bb5f66a67e6b330067e3de801fb04a"),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def sdk_root(host: Platform, explicit: str | Path | None = None) -> Path:
|
|
32
|
+
if explicit is not None:
|
|
33
|
+
return Path(explicit).expanduser().resolve()
|
|
34
|
+
home = os.environ.get("ANDROID_HOME")
|
|
35
|
+
legacy = os.environ.get("ANDROID_SDK_ROOT")
|
|
36
|
+
if home and legacy and Path(home).expanduser().resolve() != Path(legacy).expanduser().resolve():
|
|
37
|
+
raise SDKError("ANDROID_HOME and ANDROID_SDK_ROOT disagree; set one SDK root explicitly.")
|
|
38
|
+
if configured := home or legacy:
|
|
39
|
+
return Path(configured).expanduser().resolve()
|
|
40
|
+
if host.system == "windows":
|
|
41
|
+
base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
|
|
42
|
+
return base / "Android" / "Sdk"
|
|
43
|
+
if host.system == "macos":
|
|
44
|
+
return Path.home() / "Library" / "Android" / "sdk"
|
|
45
|
+
return Path.home() / "Android" / "Sdk"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def validate_packages(packages) -> tuple[str, ...]:
|
|
49
|
+
if isinstance(packages, str):
|
|
50
|
+
raise SDKError("Android packages must be a sequence, not a single string.")
|
|
51
|
+
result = []
|
|
52
|
+
for package in packages:
|
|
53
|
+
if not isinstance(package, str) or not re.fullmatch(
|
|
54
|
+
r"[A-Za-z0-9_][A-Za-z0-9_.-]*(/[A-Za-z0-9_][A-Za-z0-9_.-]*)*", package,
|
|
55
|
+
) or any(part in {".", ".."} for part in package.split("/")):
|
|
56
|
+
raise SDKError(f"Invalid Android package identifier: {package!r}.")
|
|
57
|
+
if package not in result:
|
|
58
|
+
result.append(package)
|
|
59
|
+
return tuple(result)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def command_tools(home: Path, host: Platform) -> Path | None:
|
|
63
|
+
suffix = ".exe" if host.system == "windows" else ""
|
|
64
|
+
directory = home / "cmdline-tools"
|
|
65
|
+
candidates = [directory / "latest", directory / f"sdkmgr-{TOOLS_BUILD}"]
|
|
66
|
+
if directory.is_dir():
|
|
67
|
+
versions = [p for p in directory.iterdir() if re.fullmatch(r"[0-9]+(\.[0-9]+)*", p.name)]
|
|
68
|
+
candidates.extend(sorted(versions, key=lambda p: tuple(map(int, p.name.split("."))), reverse=True))
|
|
69
|
+
for candidate in candidates:
|
|
70
|
+
if (candidate / "bin" / f"android{suffix}").is_file():
|
|
71
|
+
return candidate
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def installed_packages(home: Path) -> dict[str, str]:
|
|
76
|
+
"""Read Google's local package records without contacting the repository."""
|
|
77
|
+
try:
|
|
78
|
+
return _installed_packages(home)
|
|
79
|
+
except OSError as error:
|
|
80
|
+
raise SDKError(f"Cannot read Android package records: {error}") from error
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _installed_packages(home: Path) -> dict[str, str]:
|
|
84
|
+
result = {}
|
|
85
|
+
if not home.exists():
|
|
86
|
+
return result
|
|
87
|
+
def unreadable(error):
|
|
88
|
+
raise error
|
|
89
|
+
|
|
90
|
+
for directory, subdirectories, files in os.walk(home, onerror=unreadable):
|
|
91
|
+
subdirectories[:] = [p for p in subdirectories if not p.startswith(".")]
|
|
92
|
+
if "package.xml" not in files:
|
|
93
|
+
continue
|
|
94
|
+
# Package contents (notably NDK toolchains) are not more SDK packages.
|
|
95
|
+
subdirectories.clear()
|
|
96
|
+
record = Path(directory) / "package.xml"
|
|
97
|
+
if record.stat().st_size > 1024 * 1024:
|
|
98
|
+
raise SDKError(f"Oversized Android package record: {record}.")
|
|
99
|
+
try:
|
|
100
|
+
root = ET.parse(record).getroot()
|
|
101
|
+
for package in root.iter():
|
|
102
|
+
if package.tag.rsplit("}", 1)[-1] != "localPackage":
|
|
103
|
+
continue
|
|
104
|
+
# SDK records retain semicolons; the public CLI uses slashes.
|
|
105
|
+
name = package.attrib["path"].replace(";", "/")
|
|
106
|
+
validate_packages((name,))
|
|
107
|
+
if home.joinpath(*name.split("/")).resolve() != record.parent.resolve():
|
|
108
|
+
continue
|
|
109
|
+
revision = next((p for p in package if p.tag.rsplit("}", 1)[-1] == "revision"), None)
|
|
110
|
+
parts = {} if revision is None else {
|
|
111
|
+
p.tag.rsplit("}", 1)[-1]: p.text for p in revision
|
|
112
|
+
}
|
|
113
|
+
result[name] = ".".join(parts.get(p) or "0" for p in ("major", "minor", "micro"))
|
|
114
|
+
except (ET.ParseError, KeyError) as error:
|
|
115
|
+
raise SDKError(f"Invalid Android package record {record}: {error}") from error
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class AndroidInstallation:
|
|
121
|
+
"""An SDK root and its native Android CLI; no JVM is needed to provision it."""
|
|
122
|
+
|
|
123
|
+
home: Path
|
|
124
|
+
tools: Path
|
|
125
|
+
platform: Platform
|
|
126
|
+
|
|
127
|
+
def executable(self, name: str = "android") -> Path:
|
|
128
|
+
if name == "android":
|
|
129
|
+
suffix = ".exe" if self.platform.system == "windows" else ""
|
|
130
|
+
return self.tools / "bin" / (name + suffix)
|
|
131
|
+
if name in {"adb", "fastboot", "emulator"}:
|
|
132
|
+
suffix = ".exe" if self.platform.system == "windows" else ""
|
|
133
|
+
return self.home / ("emulator" if name == "emulator" else "platform-tools") / (name + suffix)
|
|
134
|
+
raise SDKError(f"Unknown Android executable: {name!r}.")
|
|
135
|
+
|
|
136
|
+
def environment(self, base: dict[str, str] | None = None) -> dict[str, str]:
|
|
137
|
+
result = dict(os.environ if base is None else base)
|
|
138
|
+
result["ANDROID_HOME"] = result["ANDROID_SDK_ROOT"] = str(self.home)
|
|
139
|
+
paths = (self.tools / "bin", self.home / "platform-tools", self.home / "emulator")
|
|
140
|
+
result["PATH"] = os.pathsep.join(map(str, paths)) + os.pathsep + result.get("PATH", "")
|
|
141
|
+
# Cross-OS download overrides must not silently provision unusable tools.
|
|
142
|
+
result.pop("REPO_OS_OVERRIDE", None)
|
|
143
|
+
return result
|
|
144
|
+
|
|
145
|
+
def installed(self) -> dict[str, str]:
|
|
146
|
+
return installed_packages(self.home)
|
|
147
|
+
|
|
148
|
+
def command(self, *arguments: str) -> tuple[str, ...]:
|
|
149
|
+
return (str(self.executable()), "--no-metrics", f"--sdk={self.home}", *arguments)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def find(home: Path, host: Platform) -> AndroidInstallation | None:
|
|
153
|
+
try:
|
|
154
|
+
if tools := command_tools(home, host):
|
|
155
|
+
return AndroidInstallation(home, tools, host)
|
|
156
|
+
return None
|
|
157
|
+
except OSError as error:
|
|
158
|
+
raise SDKError(f"Cannot discover Android command-line tools: {error}") from error
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _verify_packages(sdk: AndroidInstallation, packages):
|
|
162
|
+
suffix = ".exe" if sdk.platform.system == "windows" else ""
|
|
163
|
+
for package in packages:
|
|
164
|
+
directory = sdk.home.joinpath(*package.split("/"))
|
|
165
|
+
required = ()
|
|
166
|
+
if package == "platform-tools":
|
|
167
|
+
required = (f"adb{suffix}",)
|
|
168
|
+
elif package == "emulator":
|
|
169
|
+
required = (f"emulator{suffix}",)
|
|
170
|
+
elif package.startswith("platforms/"):
|
|
171
|
+
required = ("android.jar",)
|
|
172
|
+
elif package.startswith("build-tools/"):
|
|
173
|
+
required = (f"aapt2{suffix}", "d8.bat" if suffix else "d8")
|
|
174
|
+
for name in required:
|
|
175
|
+
if not (directory / name).is_file():
|
|
176
|
+
raise SDKError(
|
|
177
|
+
f"Android package {package} is registered but missing {directory / name}. "
|
|
178
|
+
"Repair it with Android Studio or Google's SDK manager, then retry; "
|
|
179
|
+
"sdkmgr will not overwrite a partial installation."
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _extract(archive: Path, destination: Path):
|
|
184
|
+
with zipfile.ZipFile(archive) as source:
|
|
185
|
+
entries = source.infolist()
|
|
186
|
+
if len(entries) > 20000 or sum(p.file_size for p in entries) > 1024 ** 3:
|
|
187
|
+
raise SDKError("Android tools archive exceeds extraction limits.")
|
|
188
|
+
for entry in entries:
|
|
189
|
+
path = PurePosixPath(entry.filename)
|
|
190
|
+
original = entry.orig_filename
|
|
191
|
+
mode = entry.external_attr >> 16
|
|
192
|
+
if (
|
|
193
|
+
path.is_absolute() or ".." in path.parts or not path.parts
|
|
194
|
+
or path.parts[0] != "cmdline-tools" or "\\" in original or "\x00" in original
|
|
195
|
+
or ":" in entry.filename or stat.S_ISLNK(mode)
|
|
196
|
+
or (stat.S_IFMT(mode) not in {0, stat.S_IFREG, stat.S_IFDIR})
|
|
197
|
+
):
|
|
198
|
+
raise SDKError(f"Unsafe Android archive member: {entry.filename!r}.")
|
|
199
|
+
target = destination.joinpath(*path.parts)
|
|
200
|
+
if entry.is_dir():
|
|
201
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
else:
|
|
203
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
with source.open(entry) as incoming, target.open("xb") as outgoing:
|
|
205
|
+
shutil.copyfileobj(incoming, outgoing)
|
|
206
|
+
target.chmod(0o755 if mode & 0o111 or path.parent.name == "bin" else 0o644)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def require_consent(*, accept_sdk_license: bool):
|
|
210
|
+
if not accept_sdk_license:
|
|
211
|
+
raise InstallationRequired((),
|
|
212
|
+
"Review Google's SDK terms and the licenses for the requested packages at "
|
|
213
|
+
"https://developer.android.com/studio#downloads before installing. "
|
|
214
|
+
"After accepting them, retry with --accept-sdk-license "
|
|
215
|
+
"(Python: accept_sdk_license=True), or install manually. "
|
|
216
|
+
"sdkmgr does not run Google's installer without this confirmation.")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def bootstrap(home: Path, host: Platform, *, accept_sdk_license: bool) -> AndroidInstallation:
|
|
220
|
+
if existing := find(home, host):
|
|
221
|
+
return existing
|
|
222
|
+
if host.tag not in TOOLS_ARCHIVES:
|
|
223
|
+
raise SDKError(f"Android command-line tools bootstrap is not supported on {host.tag}.")
|
|
224
|
+
require_consent(accept_sdk_license=accept_sdk_license)
|
|
225
|
+
target = home / "cmdline-tools" / f"sdkmgr-{TOOLS_BUILD}"
|
|
226
|
+
if target.exists():
|
|
227
|
+
raise SDKError(f"Incomplete Android tools at {target}; move them aside and retry.")
|
|
228
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
229
|
+
suffix, expected = TOOLS_ARCHIVES[host.tag]
|
|
230
|
+
url = f"https://dl.google.com/android/repository/commandlinetools-{suffix}-{TOOLS_BUILD}_latest.zip"
|
|
231
|
+
print(f"Installing Android command-line tools in {target}...", file=sys.stderr)
|
|
232
|
+
with tempfile.TemporaryDirectory(prefix=".sdkmgr-", dir=target.parent) as temporary:
|
|
233
|
+
stage = Path(temporary)
|
|
234
|
+
archive = stage / "tools.zip"
|
|
235
|
+
fetch(url, archive, limit=256 * 1024 ** 2)
|
|
236
|
+
with archive.open("rb") as stream:
|
|
237
|
+
if hashlib.file_digest(stream, "sha256").hexdigest() != expected:
|
|
238
|
+
raise SDKError("Android command-line tools SHA-256 mismatch.")
|
|
239
|
+
_extract(archive, stage)
|
|
240
|
+
extracted = stage / "cmdline-tools"
|
|
241
|
+
suffix = ".exe" if host.system == "windows" else ""
|
|
242
|
+
if not (extracted / "bin" / f"android{suffix}").is_file():
|
|
243
|
+
raise SDKError("Android tools archive is missing the Android CLI.")
|
|
244
|
+
os.replace(extracted, target)
|
|
245
|
+
return AndroidInstallation(home, target, host)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _run(sdk: AndroidInstallation, arguments, *, interactive: bool):
|
|
249
|
+
command = sdk.command(*arguments)
|
|
250
|
+
print(f"Running {display_command(command)}", file=sys.stderr)
|
|
251
|
+
return subprocess.run(
|
|
252
|
+
command, check=False, env=sdk.environment(),
|
|
253
|
+
stdin=None if interactive else subprocess.DEVNULL,
|
|
254
|
+
stdout=sys.stderr, stderr=sys.stderr, timeout=None if interactive else 1200,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def ensure(home: Path, host: Platform, packages, *,
|
|
259
|
+
interactive: bool | None, accept_sdk_license: bool = False) -> AndroidInstallation:
|
|
260
|
+
packages = validate_packages(packages)
|
|
261
|
+
try:
|
|
262
|
+
existing = find(home, host)
|
|
263
|
+
installed = installed_packages(home)
|
|
264
|
+
if existing is None or any(p not in installed for p in packages):
|
|
265
|
+
require_consent(accept_sdk_license=accept_sdk_license)
|
|
266
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
267
|
+
with FileLock(home / ".sdkmgr.lock", timeout=120):
|
|
268
|
+
sdk = bootstrap(home, host, accept_sdk_license=accept_sdk_license)
|
|
269
|
+
installed = sdk.installed()
|
|
270
|
+
_verify_packages(sdk, (p for p in packages if p in installed))
|
|
271
|
+
missing = tuple(p for p in packages if p not in installed)
|
|
272
|
+
if not missing:
|
|
273
|
+
return sdk
|
|
274
|
+
attached = interactive is not False and sys.stdin.isatty() and sys.stderr.isatty()
|
|
275
|
+
require_consent(accept_sdk_license=accept_sdk_license)
|
|
276
|
+
result = _run(sdk, ("sdk", "install", *missing), interactive=attached)
|
|
277
|
+
if result.returncode:
|
|
278
|
+
raise SDKError(f"Android installer exited with status {result.returncode}; retry after resolving its error.")
|
|
279
|
+
if remaining := tuple(p for p in packages if p not in sdk.installed()):
|
|
280
|
+
command = sdk.command("sdk", "install", *remaining)
|
|
281
|
+
raise InstallationRequired(command,
|
|
282
|
+
f"Android packages still missing: {', '.join(remaining)}. "
|
|
283
|
+
f"Inspect the installer output and retry {display_command(command)}. "
|
|
284
|
+
"The installer may have reported unavailable packages or unmet prerequisites.")
|
|
285
|
+
_verify_packages(sdk, packages)
|
|
286
|
+
return sdk
|
|
287
|
+
except Timeout as error:
|
|
288
|
+
raise SDKError("Another sdkmgr Android install is active; retry later.") from error
|
|
289
|
+
except (OSError, subprocess.SubprocessError, zipfile.BadZipFile) as error:
|
|
290
|
+
raise SDKError(f"Android provisioning failed: {error}") from error
|
sdkmgr/bootstrap.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from filelock import FileLock, Timeout
|
|
9
|
+
|
|
10
|
+
from .download import fetch
|
|
11
|
+
from .errors import SDKError
|
|
12
|
+
from .providers import Platform
|
|
13
|
+
|
|
14
|
+
MISE_VERSION = "2026.9.12"
|
|
15
|
+
# Published in the pinned release's SHASUMS256.txt; no remote installer executes.
|
|
16
|
+
MISE_BINARIES = {
|
|
17
|
+
"linux-x64": ("linux-x64-musl", "e59a38683c9d776e0ea6c7b35c9829de915bb7df15d16d0bf6890afa0278bd7c"),
|
|
18
|
+
"linux-arm64": ("linux-arm64-musl", "2d2982b12f7a13894aa88272e4cd360e68591fe105b43cff29b1ac4588c6fe83"),
|
|
19
|
+
"macos-x64": ("macos-x64", "fa1e35456f1e27a4555a1ccec82d5367d7037f3b4f46d48b83c4a3bceb4d78f2"),
|
|
20
|
+
"macos-arm64": ("macos-arm64", "f20d7cc555a5b0ee7b8a504acbc2583be1b0848d64115cfbe84119ceb705025b"),
|
|
21
|
+
"windows-x64": ("windows-x64.exe", "8bc3a2c46246bb00fc49f467c678cf9ff321f707c533ad707061d0f8c7b639b2"),
|
|
22
|
+
"windows-arm64": ("windows-arm64.exe", "6beeb61b829ba60ef3bb1fdda5717a41b8fb91bd400109de8b05b1700a107d8e"),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _target(root: Path, host: Platform) -> Path:
|
|
27
|
+
return root / "managers" / f"mise-{MISE_VERSION}-{host.tag}" / (
|
|
28
|
+
"mise.exe" if host.system == "windows" else "mise"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _verify(path: Path, host: Platform):
|
|
33
|
+
with path.open("rb") as source:
|
|
34
|
+
digest = hashlib.file_digest(source, "sha256").hexdigest()
|
|
35
|
+
if digest != MISE_BINARIES[host.tag][1]:
|
|
36
|
+
raise SDKError(f"SHA-256 mismatch for {path}; move it aside and retry.")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def find(root: Path, host: Platform) -> str | None:
|
|
40
|
+
target = _target(root, host)
|
|
41
|
+
try:
|
|
42
|
+
if target.exists():
|
|
43
|
+
_verify(target, host)
|
|
44
|
+
return str(target)
|
|
45
|
+
return None
|
|
46
|
+
except OSError as error:
|
|
47
|
+
raise SDKError(f"Cannot read sdkmgr's mise installation: {error}") from error
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def ensure(root: Path, host: Platform) -> str:
|
|
51
|
+
target = _target(root, host)
|
|
52
|
+
try:
|
|
53
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
with FileLock(target.parent / ".install.lock", timeout=120):
|
|
55
|
+
if existing := find(root, host):
|
|
56
|
+
return existing
|
|
57
|
+
suffix = MISE_BINARIES[host.tag][0]
|
|
58
|
+
url = (
|
|
59
|
+
f"https://github.com/jdx/mise/releases/download/v{MISE_VERSION}/"
|
|
60
|
+
f"mise-v{MISE_VERSION}-{suffix}"
|
|
61
|
+
)
|
|
62
|
+
print(f"Installing mise {MISE_VERSION} in {target.parent}...", file=sys.stderr)
|
|
63
|
+
with tempfile.TemporaryDirectory(prefix=".install-", dir=target.parent) as temporary:
|
|
64
|
+
staged = Path(temporary) / target.name
|
|
65
|
+
fetch(url, staged, limit=128 * 1024 ** 2)
|
|
66
|
+
_verify(staged, host)
|
|
67
|
+
staged.chmod(0o755)
|
|
68
|
+
result = subprocess.run(
|
|
69
|
+
[str(staged), "--version"], check=True, capture_output=True,
|
|
70
|
+
text=True, timeout=10, stdin=subprocess.DEVNULL,
|
|
71
|
+
)
|
|
72
|
+
if MISE_VERSION not in result.stdout.split():
|
|
73
|
+
raise SDKError("Downloaded mise did not report the pinned version.")
|
|
74
|
+
os.replace(staged, target)
|
|
75
|
+
return str(target)
|
|
76
|
+
except Timeout as error:
|
|
77
|
+
raise SDKError("Timed out waiting for mise bootstrap; retry later.") from error
|
|
78
|
+
except (OSError, subprocess.SubprocessError, UnicodeError) as error:
|
|
79
|
+
raise SDKError(f"Could not bootstrap mise: {error}") from error
|
sdkmgr/cli.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from . import InstallationRequired, Manager, SDKError, __version__
|
|
5
|
+
from .android import installed_packages, sdk_root
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main(argv=None) -> int:
|
|
9
|
+
parser = argparse.ArgumentParser(description="Provision Node, Java 21/25, and Android SDK packages.")
|
|
10
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
11
|
+
parser.add_argument("--root", help="Private manager bootstrap directory")
|
|
12
|
+
parser.add_argument("--non-interactive", action="store_true", help="Never prompt; defer JDK installs")
|
|
13
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
14
|
+
commands.add_parser("list", help="List selected-manager Node and discovered JDK installations")
|
|
15
|
+
android_parser = commands.add_parser("android", help="Manage Android SDK packages")
|
|
16
|
+
android_parser.add_argument("--sdk-root", help="Override ANDROID_HOME and the standard SDK location")
|
|
17
|
+
android_commands = android_parser.add_subparsers(dest="android_command", required=True)
|
|
18
|
+
android_commands.add_parser("list", help="Read installed packages without network access")
|
|
19
|
+
android_install = android_commands.add_parser("install", help="Install missing packages and their dependencies")
|
|
20
|
+
android_install.add_argument("packages", nargs="*", help="SDK IDs such as platforms/android-35")
|
|
21
|
+
android_install.add_argument("--accept-sdk-license", action="store_true",
|
|
22
|
+
help="Confirm acceptance of Google's SDK and requested package licenses")
|
|
23
|
+
for command in ("install", "which"):
|
|
24
|
+
subparser = commands.add_parser(command)
|
|
25
|
+
subparser.add_argument("sdk", help="node@X.Y.Z, java@21, or java@25")
|
|
26
|
+
args = parser.parse_args(argv)
|
|
27
|
+
try:
|
|
28
|
+
manager = Manager(args.root, interactive=False if args.non_interactive else None)
|
|
29
|
+
if args.command == "android":
|
|
30
|
+
if args.android_command == "install":
|
|
31
|
+
sdk = manager.ensure_android(args.packages, sdk_root=args.sdk_root,
|
|
32
|
+
accept_sdk_license=args.accept_sdk_license)
|
|
33
|
+
print(sdk.home)
|
|
34
|
+
else:
|
|
35
|
+
home = sdk_root(manager.platform, args.sdk_root)
|
|
36
|
+
for package, version in sorted(installed_packages(home).items()):
|
|
37
|
+
print(f"{package}\t{version}")
|
|
38
|
+
return 0
|
|
39
|
+
if args.command == "list":
|
|
40
|
+
for installation in manager.installed():
|
|
41
|
+
print(f"{installation.tool}@{installation.version}\t{installation.home}")
|
|
42
|
+
return 0
|
|
43
|
+
tool, separator, version = args.sdk.partition("@")
|
|
44
|
+
if not separator or not tool or not version:
|
|
45
|
+
raise SDKError("Specify a tool and exact version, for example node@22.14.0.")
|
|
46
|
+
if args.command == "install":
|
|
47
|
+
print(f"Ensuring {tool}@{version}...", file=sys.stderr)
|
|
48
|
+
installation = manager.ensure(tool, version=version)
|
|
49
|
+
else:
|
|
50
|
+
installation = manager.find(tool, version=version)
|
|
51
|
+
if installation is None:
|
|
52
|
+
raise SDKError(f"{args.sdk} is not installed; run sdkmgr install {args.sdk}.")
|
|
53
|
+
print(installation.executable())
|
|
54
|
+
return 0
|
|
55
|
+
except InstallationRequired as error:
|
|
56
|
+
print(f"sdkmgr: {error}", file=sys.stderr)
|
|
57
|
+
return 2
|
|
58
|
+
except SDKError as error:
|
|
59
|
+
print(f"sdkmgr: {error}", file=sys.stderr)
|
|
60
|
+
return 1
|
|
61
|
+
except KeyboardInterrupt:
|
|
62
|
+
print("sdkmgr: interrupted", file=sys.stderr)
|
|
63
|
+
return 130
|
sdkmgr/core.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from platformdirs import user_data_path
|
|
4
|
+
|
|
5
|
+
from . import android, bootstrap, java, mise, nvm
|
|
6
|
+
from .errors import SDKError
|
|
7
|
+
from .installation import Installation, validated_node
|
|
8
|
+
from .providers import Platform, validate_node_version
|
|
9
|
+
from .system import install_package
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Manager:
|
|
13
|
+
"""Provision Node through mise/nvm and Java 21/25 through OS packages.
|
|
14
|
+
|
|
15
|
+
Existing mise takes precedence over nvm; with neither installed, ensure()
|
|
16
|
+
bootstraps a pinned mise under root. find() and installed() never install.
|
|
17
|
+
Java package installation requires terminal confirmation; interactive=False
|
|
18
|
+
raises InstallationRequired instead. Authentication belongs to the OS.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(self, root: str | Path | None = None, *, interactive: bool | None = None):
|
|
22
|
+
self.root = Path(root) if root is not None else user_data_path("sdkmgr", appauthor=False)
|
|
23
|
+
self.root = self.root.expanduser().resolve()
|
|
24
|
+
self.interactive = interactive
|
|
25
|
+
self.platform = Platform.current()
|
|
26
|
+
|
|
27
|
+
def _node_provider(self, *, install: bool = False):
|
|
28
|
+
if executable := mise.executable() or bootstrap.find(self.root, self.platform):
|
|
29
|
+
return mise, executable
|
|
30
|
+
if existing := nvm.discover(self.platform):
|
|
31
|
+
return nvm, existing
|
|
32
|
+
if install:
|
|
33
|
+
return mise, bootstrap.ensure(self.root, self.platform)
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
def _check_tool(self, tool: str, version: str):
|
|
37
|
+
if tool != "node":
|
|
38
|
+
raise SDKError(f"Unknown tool: {tool!r}; expected node or java.")
|
|
39
|
+
validate_node_version(version)
|
|
40
|
+
|
|
41
|
+
def ensure(self, tool: str, *, version: str) -> Installation:
|
|
42
|
+
"""Return a matching installation, installing only when discovery fails."""
|
|
43
|
+
if tool == "java":
|
|
44
|
+
if existing := self.find(tool, version=version):
|
|
45
|
+
return existing
|
|
46
|
+
install_package(java.install_command(self.platform, version), interactive=self.interactive)
|
|
47
|
+
if installed := self.find(tool, version=version):
|
|
48
|
+
return installed
|
|
49
|
+
raise SDKError(
|
|
50
|
+
f"The installer completed, but JDK {version} was not found. "
|
|
51
|
+
"Set JAVA_HOME to its installation directory and retry."
|
|
52
|
+
)
|
|
53
|
+
self._check_tool(tool, version)
|
|
54
|
+
provider, context = self._node_provider(install=True)
|
|
55
|
+
if home := provider.node_installations(context, self.platform).get(version):
|
|
56
|
+
return validated_node(home, version, self.platform)
|
|
57
|
+
provider.install_node(context, version, self.platform)
|
|
58
|
+
if home := provider.node_installations(context, self.platform).get(version):
|
|
59
|
+
return validated_node(home, version, self.platform)
|
|
60
|
+
raise SDKError(f"{provider.__name__.rsplit('.', 1)[-1]} completed, but Node {version} was not found.")
|
|
61
|
+
|
|
62
|
+
def find(self, tool: str, *, version: str) -> Installation | None:
|
|
63
|
+
"""Look up an installation without downloading anything."""
|
|
64
|
+
if tool == "java":
|
|
65
|
+
found = java.find(self.platform, version)
|
|
66
|
+
return Installation(tool, found[1], found[0], self.platform) if found else None
|
|
67
|
+
self._check_tool(tool, version)
|
|
68
|
+
if selection := self._node_provider():
|
|
69
|
+
provider, context = selection
|
|
70
|
+
if home := provider.node_installations(context, self.platform).get(version):
|
|
71
|
+
return validated_node(home, version, self.platform)
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
def installed(self) -> list[Installation]:
|
|
75
|
+
"""List the selected Node manager's installations and discovered Java 21/25."""
|
|
76
|
+
result = []
|
|
77
|
+
if selection := self._node_provider():
|
|
78
|
+
provider, context = selection
|
|
79
|
+
for version, home in provider.node_installations(context, self.platform).items():
|
|
80
|
+
result.append(validated_node(home, version, self.platform))
|
|
81
|
+
for version in ("21", "25"):
|
|
82
|
+
if installation := self.find("java", version=version):
|
|
83
|
+
result.append(installation)
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
def find_android(self, *, sdk_root: str | Path | None = None) -> android.AndroidInstallation | None:
|
|
87
|
+
"""Discover Android command-line tools without installing or running them."""
|
|
88
|
+
return android.find(android.sdk_root(self.platform, sdk_root), self.platform)
|
|
89
|
+
|
|
90
|
+
def ensure_android(self, packages=(), *, sdk_root: str | Path | None = None,
|
|
91
|
+
accept_sdk_license: bool = False) -> android.AndroidInstallation:
|
|
92
|
+
"""Ensure Android package IDs, delegating dependencies to Google's CLI.
|
|
93
|
+
|
|
94
|
+
Existing packages are not updated. accept_sdk_license confirms prior
|
|
95
|
+
acceptance of Google's SDK and requested package licenses. It is required
|
|
96
|
+
before any downloads or package installation, but not for existing SDKs.
|
|
97
|
+
IDs use slashes, for example platforms/android-35. No JDK is required;
|
|
98
|
+
no emulator is launched or virtual device created.
|
|
99
|
+
"""
|
|
100
|
+
packages = android.validate_packages(packages)
|
|
101
|
+
home = android.sdk_root(self.platform, sdk_root)
|
|
102
|
+
return android.ensure(home, self.platform, packages,
|
|
103
|
+
interactive=self.interactive, accept_sdk_license=accept_sdk_license)
|
sdkmgr/download.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import tempfile
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from .errors import SDKError
|
|
9
|
+
|
|
10
|
+
MAX_DOWNLOAD = 1024 ** 3
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def fetch(url: str, destination: Path, *, limit: int = MAX_DOWNLOAD):
|
|
14
|
+
"""Download over HTTPS, retrying transient failures without retaining partial data."""
|
|
15
|
+
if not url.startswith("https://"):
|
|
16
|
+
raise SDKError("Downloads require HTTPS.")
|
|
17
|
+
for attempt in range(3):
|
|
18
|
+
staged = None
|
|
19
|
+
try:
|
|
20
|
+
started = time.monotonic()
|
|
21
|
+
with httpx.Client(timeout=30, follow_redirects=False) as client:
|
|
22
|
+
current = url
|
|
23
|
+
for redirect in range(6):
|
|
24
|
+
with client.stream("GET", current) as response:
|
|
25
|
+
if response.is_redirect:
|
|
26
|
+
location = response.headers.get("location")
|
|
27
|
+
if not location:
|
|
28
|
+
raise SDKError("Download redirect is missing its Location header.")
|
|
29
|
+
target = response.url.join(location)
|
|
30
|
+
if target.scheme != "https":
|
|
31
|
+
raise SDKError("Refusing an insecure download redirect.")
|
|
32
|
+
current = str(target)
|
|
33
|
+
continue
|
|
34
|
+
response.raise_for_status()
|
|
35
|
+
size = 0
|
|
36
|
+
with tempfile.NamedTemporaryFile(
|
|
37
|
+
dir=destination.parent, prefix=".download-", delete=False,
|
|
38
|
+
) as output:
|
|
39
|
+
staged = Path(output.name)
|
|
40
|
+
for chunk in response.iter_bytes(128 * 1024):
|
|
41
|
+
size += len(chunk)
|
|
42
|
+
if size > limit or time.monotonic() - started > 600:
|
|
43
|
+
raise SDKError("Download exceeded its size or time limit.")
|
|
44
|
+
output.write(chunk)
|
|
45
|
+
os.replace(staged, destination)
|
|
46
|
+
return
|
|
47
|
+
raise SDKError("Too many download redirects.")
|
|
48
|
+
except httpx.HTTPError as error:
|
|
49
|
+
retryable = not isinstance(error, httpx.HTTPStatusError) or (
|
|
50
|
+
error.response.status_code in {408, 429, 500, 502, 503, 504}
|
|
51
|
+
)
|
|
52
|
+
if attempt == 2 or not retryable:
|
|
53
|
+
raise SDKError(f"Could not download {url}: {error}") from error
|
|
54
|
+
time.sleep(0.5 * (attempt + 1))
|
|
55
|
+
except (OSError, httpx.InvalidURL) as error:
|
|
56
|
+
raise SDKError(f"Could not download {url}: {error}") from error
|
|
57
|
+
finally:
|
|
58
|
+
if staged is not None:
|
|
59
|
+
staged.unlink(missing_ok=True)
|
sdkmgr/errors.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
class SDKError(Exception):
|
|
2
|
+
"""A tool could not be resolved, downloaded, or installed safely."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class InstallationRequired(SDKError):
|
|
6
|
+
"""Installation was deferred; run command and retry the original operation."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, command: tuple[str, ...], message: str):
|
|
9
|
+
self.command = command
|
|
10
|
+
super().__init__(message)
|