Metadata-Version: 2.5
Name: sdkmgr
Version: 0.3.1
Summary: Discover and provision Node, Java, Clang, Tailwind, Chrome, and Android development tools.
Project-URL: Homepage, https://github.com/pyjs/sdkmgr
Project-URL: Release notes, https://github.com/pyjs/sdkmgr/releases
Project-URL: Source, https://github.com/pyjs/sdkmgr
Project-URL: Tracker, https://github.com/pyjs/sdkmgr/issues
Project-URL: Discord, https://discord.gg/YktKpQPG7
Project-URL: X, https://x.com/pyjsdev
Author-email: Lex Berezhny <lex@damoti.com>
License-Expression: BSD-3-Clause
License-File: LICENSE
Keywords: android,browser,chrome,clang,java,node,sdk,tailwind,toolchain
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Software Development :: Build Tools
Requires-Python: >=3.12
Requires-Dist: filelock>=3.16
Requires-Dist: httpx<1,>=0.27
Requires-Dist: platformdirs>=4
Description-Content-Type: text/markdown

# sdkmgr

A Python library and command-line tool for finding development SDKs and
installing missing tools through established package managers.

This is an alpha project. Java provisioning supports **JDK 21 and 25** through
apt, dnf, pacman, Homebrew, or WinGet.
There is no JDK fallback,
vendor requirement for existing installations, or exact patch-version pinning.

Node provisioning prefers existing mise, otherwise existing nvm, otherwise
bootstraps mise. sdkmgr no longer downloads Node archives itself. Android
provisioning delegates package installation to Google's command-line tools.
sdkmgr also provisions Clang, Tailwind CSS, and Chrome for
Testing, resolves Android build-tool paths, and caches checksum-locked files.
These APIs are available starting with sdkmgr 0.3.0.

## Install

Python 3.12 or newer is required. Install the CLI in an isolated environment:

```sh
uv tool install sdkmgr
sdkmgr --help
```

For a library dependency, use `pip install sdkmgr` in your environment.
To try unreleased source, use
`uv tool install git+https://github.com/pyjs/sdkmgr.git` instead.
Installing sdkmgr itself does not download any SDK or change tool selections.

## Android SDK packages

```sh
sdkmgr android list
sdkmgr android install platform-tools platforms/android-35 build-tools/35.0.1
```

Android provisioning uses Google's **`android sdk` CLI only**. There is no
legacy `sdkmanager` adapter or fallback. Package IDs use the CLI's slash
syntax; sdkmgr translates semicolons only when reading Google's package
records. Existing SDK packages do not need to be reinstalled when changing
the management tool.

Root selection is `--sdk-root`, then `ANDROID_HOME`/`ANDROID_SDK_ROOT`, then
the standard user location on Linux, macOS, or Windows. Conflicting environment
variables require an explicit root. The install command prints the SDK root
to stdout; progress goes to stderr.

Installation reuses registered packages without upgrading them, verifies key
files for platform/build tools, and delegates dependencies to Google.
Packages such as `platform-tools` select Google's stable release when absent,
not a pinned revision. This is not yet a reproducible package lock.
The `list` command reads local records without network access and does not
require the Android CLI. A missing SDK root produces an empty listing.

Before downloading the CLI or installing missing packages, review Google's
[SDK terms](https://developer.android.com/studio/terms) and the requested
packages' licenses, then explicitly confirm acceptance:

```sh
sdkmgr android install --accept-sdk-license platform-tools platforms/android-35
```

This flag acknowledges prior acceptance; sdkmgr never supplies automatic
affirmative input. Google's new CLI no longer provides the old
`--licenses` flow, so sdkmgr requires confirmation before invoking installation,
even if the CLI would proceed without prompting. Reusing already installed
packages does not require the flag. Failed or incomplete installs are reported
even when Google's command exits successfully.

The CLI bootstrap archive is pinned and SHA-256 verified, extracted in staging,
then atomically published to a separate command-tools directory. **The launcher
fetches Google's CLI payload on first use.** That subsequent payload and its
cache are managed by Google, not pinned or independently verified by sdkmgr.
Commands launched by sdkmgr include `--no-metrics` and an explicit `--sdk`.
Discovery and no-op provisioning do not launch the CLI.

Existing tools are not replaced. SDK installs are serialized among sdkmgr
callers; do not run Android Studio's SDK installer concurrently. Interrupted
installs are rechecked on retry; registered but damaged packages need explicit
repair. No external JDK is required for provisioning. Obtain one separately
with `ensure("java", version="21")` when your build needs it.

Bootstrap covers Linux x64, macOS x64/ARM64, and Windows x64. Other hosts need
compatible command-line tools installed separately. Windows/macOS support has
simulated tests but still needs real installation verification. Linux bootstrap
and existing SDK reuse are verified locally. See the
[CLI reference](https://developer.android.com/tools/agents/android-cli).

```python
from sdkmgr import Manager

sdk = Manager().ensure_android(
    ["platform-tools", "platforms/android-35", "build-tools/35.0.1"],
    accept_sdk_license=True,  # Only after reviewing and accepting the terms.
)
adb = sdk.executable("adb")
environment = sdk.environment()

# Select exact packages; lookup never installs or selects a newer revision.
aapt2 = sdk.build_tool("aapt2", version="35.0.1")
d8 = sdk.build_tool("d8", version="35.0.1")
android_jar = sdk.platform_jar(35)
```

Build-tool lookup supports `aapt`, `aapt2`, `aidl`, `d8`, `dexdump`, `zipalign`,
and `apksigner`, including Windows `.exe`/`.bat` names. Missing files receive
package-specific diagnostics. Lookup also works for existing SDKs whose
files are present without local package records. It does not verify package
checksums or create signing keys.

The returned environment sets SDK paths without changing your shell. Emulator
packages and system images must be requested explicitly. Provisioning never
creates an AVD or starts an emulator.

For an opt-in live smoke, after accepting the terms:

```sh
python scripts/smoke_android.py --accept-sdk-license
```

The smoke bootstraps tools and installs API 35 into a temporary SDK, then
checks a no-op retry and removes that SDK. Google's separate CLI payload cache
may persist; no packages are added to the user's existing SDK.

This bootstraps the CLI if needed, installs API 35 into a temporary SDK,
verifies it, checks a no-op retry, and removes the temporary packages.

## Java from the command line

```sh
sdkmgr install java@21
sdkmgr install java@25
sdkmgr which java@21
sdkmgr list
```

`which` only discovers installations. `install` first looks for a complete,
working JDK of the requested major version. It accepts maintenance updates
and does not require Temurin. A runtime without `javac` and `jar` is not enough.

Discovery checks `JAVA_HOME`, resolved executables on `PATH`, and platform
installation locations. macOS also uses `java_home` and Homebrew's prefix;
Windows also checks registered JDK uninstall locations. It verifies both
`java` and `javac` with ten-second subprocess timeouts.

If the JDK is missing, sdkmgr shows the package-manager command:

```text
JDK installation requires this command:

  /usr/bin/sudo -- /usr/bin/apt-get install openjdk-21-jdk

You can quit, run it yourself, and restart your original command.
Completed installations will be detected automatically.
```

Press Enter to continue, or type `q` to exit. Authentication is handled by
sudo or the Windows installer, never by sdkmgr. On typical sudo systems,
the password requested is your account password, not the root password.
Package-manager confirmation and license prompts remain visible.

If you exit, run the displayed command yourself and restart the command you
originally ran. Discovery happens again, so sdkmgr proceeds without repeating
the completed installation. No separate resume flag or checkpoint is needed.

Without an interactive terminal, or with `--non-interactive`, missing Java
produces instructions and exit status **2** without running an installer:

```sh
sdkmgr --non-interactive install java@21
```

Successful `install` and `which` print the executable path to stdout.
Messages and package-manager output go to stderr. Other expected failures
return status 1; interruption returns 130.

## Package providers

| Platform | Installation command, before elevation |
| --- | --- |
| Debian/Ubuntu | `apt-get install openjdk-21-jdk` |
| Fedora/RHEL family | `dnf install java-21-openjdk-devel` |
| Arch/Omarchy | `pacman -S --needed jdk21-openjdk` |
| macOS | `brew install openjdk@21` |
| Windows | `winget install --exact --id Microsoft.OpenJDK.21 --source winget` |

Java 25 uses the corresponding `25` package names. Linux commands use sudo
unless already running as root. Homebrew is never run through sudo by sdkmgr;
Windows installers handle elevation through the OS.

The package manager must already be installed. sdkmgr does not add repositories,
refresh package indexes, install Homebrew, accept licenses automatically, or
silently substitute another major version. If the requested package is absent
from your OS repositories, installation fails with the package-manager output.
Install an appropriate JDK yourself or use a supported OS release, then retry.

Arch and Omarchy require complete system updates; partial upgrades are not
supported. sdkmgr does not run `pacman -Sy` or perform a whole-system upgrade.
If packages cannot be installed, use your normal system updater (Omarchy's
updater on Omarchy), then retry the original sdkmgr command.

sdkmgr does not explicitly change Java alternatives, shell profiles, or global
environment variables. Package-manager installers can have their own system
side effects. Security updates remain the responsibility of the package manager
and the user's update policy; `ensure()` does not force upgrades.

## Node manager selection

```sh
sdkmgr install node@22.14.0
sdkmgr which node@22.14.0
```

Selection follows this order:

1. Existing mise on `PATH`, in `~/.local/bin`, or previously bootstrapped by
   sdkmgr under its private root.
2. Existing Unix nvm or nvm-windows.
3. Bootstrap a pinned mise if neither manager is present.

A selected manager's failure does not trigger fallback to another provider.
`which` and `list` never bootstrap managers or install Node. A standalone Node
on `PATH` does not bypass this selection order.

### Mise

When mise is present, sdkmgr prefers it, including
versions that are installed but not currently active. It requests an exact
version through mise's built-in Node provider, validates `node --version`, and
requires the bundled npm and npx executables. A missing version is installed
in mise's storage, not sdkmgr's cache. No sudo is used.

Discovery uses `mise ls --installed --json core:node`; installation uses
`mise install core:node@VERSION --jobs=1`. sdkmgr does not run `mise use`,
activate a shell, edit project configuration, or switch global defaults.
Returned paths bypass shims, and `environment()` selects Node only for the
subprocess receiving that environment.

The adapter requires mise's `--no-config` and `--no-hooks` options. These
prevent unrelated project configuration or hooks from running. It also
disables source compilation and the default extra-npm-packages file. Existing
environment settings, including mise's data directory, remain available.
Configuration-file-only customizations are intentionally not loaded.

A broken or incompatible mise installation produces an error instead of a
silent fallback to another installer. Upgrade or repair mise and retry.
Discovery commands have a 30-second timeout; installation has ten minutes and
sends progress to stderr. `find()` never installs a missing version.

Bootstrap downloads mise 2026.9.12 from its official GitHub release and checks
the binary against a SHA-256 pinned in sdkmgr before executing it. No shell
installer runs. Linux uses the musl binary; macOS and Windows use their native
binaries. Both x64 and ARM64 assets are pinned. The executable is installed
atomically under `ROOT/managers/`, without changing `PATH` or shell profiles.
Concurrent bootstrap calls share a lock. A corrupt cached binary produces an
error rather than being silently overwritten.

### Nvm

Unix discovery checks `NVM_DIR`, `~/.nvm`, `XDG_CONFIG_HOME/nvm`, and common
Homebrew/Arch script locations. Inactive versions are reusable. Installation
sources the existing script in a noninteractive Bash subprocess without
loading shell profiles. It requests a prebuilt Node and skips default extra
npm packages. To avoid nvm creating a default alias, installation uses a
temporary `NVM_DIR`; only the validated version is moved into the user's store.

On Windows, sdkmgr discovers `nvm.exe` through `PATH` or `NVM_HOME`, then asks
nvm-windows v1 or v2 for its installation root. It installs missing versions
without invoking `nvm use`. Existing manager settings still apply, including
v2's configured extra npm packages. Version 2 automatically activates its first
Node installation, so an empty v2 store produces manual-install instructions
and exit status 2. Run the displayed command and retry. sdkmgr does not modify
registry preferences or undo manager-owned state changes.

`--non-interactive` disables prompts for privileged Java installation; ordinary
Node provisioning and mise bootstrap remain automatic, including in CI.

## Python integration

```python
import subprocess

from sdkmgr import Manager

manager = Manager()
jdk = manager.ensure("java", version="21")

subprocess.run(
    [jdk.executable("javac"), "Example.java"],
    env=jdk.environment(),
    check=True,
)
```

`environment()` returns a new complete environment with the selected JDK's
`bin` directory first in `PATH` and its home in `JAVA_HOME`. Pass an explicit
mapping to `environment(base)` if needed. Neither form mutates `os.environ`.
`Installation.version` records the detected runtime version, such as
`21.0.12`, rather than just the requested major.

`find("java", version="21")` returns an installation or `None` without
installing anything. `installed()` returns discovered Java 21/25 installations
and versions from the selected Node manager. Old prototype Node caches are not
used or removed.

For unattended callers, disable prompting explicitly:

```python
from sdkmgr import InstallationRequired, Manager

manager = Manager(interactive=False)
try:
    jdk = manager.ensure("java", version="21")
except InstallationRequired as error:
    required_command = error.command
    raise
```

`InstallationRequired.command` is an argument tuple; no shell parsing is needed.
`InstallationRequired` is an `SDKError`. Library calls never terminate the
calling process. Even `interactive=True` cannot launch an installer without
terminal input and terminal stderr. Installer processes inherit stdin;
sdkmgr never reads, stores, forwards, or logs a password.

After an installer reports success, discovery runs again. If its installation
location is not recognized, set `JAVA_HOME` and retry. Long-running interactive
package installations are not given an arbitrary timeout while authentication
or user confirmation may be pending.

## Clang and LLVM toolchains

```sh
sdkmgr clang which
sdkmgr clang install
sdkmgr clang install --sanitizer address --sanitizer undefined
```

```python
from sdkmgr import Manager

manager = Manager(interactive=False)
compiler = manager.ensure_clang()
clang = compiler.executable()
environment = compiler.environment()
```

`find_clang()` discovers and checks the compiler version without installing
or compiling. Selection is an explicit `executable=...` (CLI `--executable`),
then `CLANG`, then PATH and platform locations. Explicit selections fail
rather than falling back. Apple Clang is accepted; its reported version is
not an upstream LLVM version guarantee.

`ensure_clang()` installs missing Clang through apt, dnf, pacman, Homebrew, or
WinGet with the same confirmation and manual-retry flow as Java. It uses the
OS repository's version, not an exact LLVM pin. It does not bootstrap package
managers, add repositories, switch compiler alternatives, or repair an
existing compiler automatically.

Linux installation requests C development tools. Debian/Ubuntu use `clang`
and `build-essential`, relying on the distribution's compiler dependencies;
they do not assume a cross-release sanitizer runtime package name. Requested
sanitizer probes report missing runtimes for manual installation. Fedora/Arch
also request `compiler-rt`.
Homebrew installs `llvm`; macOS also needs Xcode Command Line Tools. WinGet
installs `LLVM.LLVM`; Windows additionally needs Visual Studio C++ Build Tools
and a Windows SDK. Those macOS/Windows prerequisites must already be present
or installed manually. A developer terminal may be needed on Windows.

After discovery or installation, `ensure_clang()` compiles and links a small
C executable using libc and a separate LLVM IR shared library. It never runs
the outputs and removes the temporary files. Requested sanitizer checks cover
linking, not sanitizer runtime execution. Existing compiler validation is
also available as `compiler.validate(sanitizers=("address",))`.

These checks diagnose missing headers, linkers, and runtime libraries; they
do not establish support for every LLVM IR feature, target architecture, or
application ABI. PyJS retains ownership of those checks and its compile flags.
Clang paths have no dependency on PyJS environment variables: the integration
can forward `PYJS_CLANG` explicitly.

For Clang and Tailwind, `environment()` prepends the executable's entire
directory to PATH, including shared directories such as `/usr/bin`. This can
shadow unrelated user tools or version-manager shims in child processes.
To preserve PATH ordering, invoke the absolute `executable()` path and omit
`env`, or pass your unchanged environment. No parent environment is modified.

## Standalone Tailwind CSS

```sh
sdkmgr install tailwindcss@4.3.3
sdkmgr which tailwindcss@4.3.3
```

```python
tailwind = manager.ensure("tailwindcss", version="4.3.3")
executable = tailwind.executable()
environment = tailwind.environment()
```

An exact-version `TAILWINDCSS` override or executable on PATH is reused.
An invalid explicit override fails rather than silently downloading a
replacement. Otherwise sdkmgr downloads the official standalone executable
into its private tool cache. Node is not required.

Downloads are versioned, locked, staged, checked against the release's
`sha256sums.txt`, and version-validated before publication to the cache.
Cached binaries are rehashed on lookup. Interrupted downloads are discarded;
corrupt caches receive a repair diagnostic instead of being overwritten.
The checksum manifest comes from the same upstream release as the executable;
it is not an independently pinned signature or trust root.

Supported downloads cover Linux x64/ARM64 with glibc or musl, macOS x64/ARM64,
and Windows x64. Windows ARM64 needs an explicitly installed compatible
executable; sdkmgr does not silently substitute the x64 build. CSS input,
application dependencies, and build execution remain the caller's concern.

The generic `list` command still lists Node and Java installations only.
Use `clang which`, `which tailwindcss@X.Y.Z`, and `android list` for the
other tools.

## Browser discovery and provisioning

```sh
sdkmgr browser which
sdkmgr browser install
sdkmgr browser install --managed
```

```python
browser = manager.find_browser()  # No downloads or browser processes.
browser = manager.ensure_browser()  # Download only when none is found.
pinned = manager.ensure_browser(managed=True)
executable = pinned.executable()
```

Discovery checks an explicit `executable`, then `CHROME_BIN`, PATH, standard
Chrome/Chromium locations, and the pinned cache. A missing or non-executable
explicit selection is an error, not permission to download a replacement.
Discovery does not launch browsers or infer installed versions; external
installations have `version=None`. `managed=True` ignores PATH and `CHROME_BIN`
and selects only the pinned Chrome for Testing copy. It cannot be combined
with an explicit executable. The CLI exposes `--executable` and `--managed`.

The managed provider pins Chrome for Testing **155.0.8059.84** and archive
SHA-256 values for Linux glibc x64/ARM64, macOS x64/ARM64, and Windows x64.
Musl hosts need an existing compatible Chromium. The pins
were calculated from Google's official HTTPS archives; they are not publisher
signatures. Downloads are locked, verified, extracted into bounded staging,
and published atomically. macOS framework symlinks must stay within the
extracted application. Installed files are not rehashed on each discovery;
the retained download is verified whenever the artifact cache reads it.

sdkmgr does not install browser OS libraries, configure Linux sandboxes, set
a default browser, create profiles, or control browser processes. A browser
found on disk can still fail at launch due to missing host prerequisites.
Browser testing libraries retain that responsibility. Windows ARM64 needs an
existing compatible browser rather than a silently substituted x64 download.

## Android SDK inspection without an installer

```python
sdk = manager.inspect_android(sdk_root="/path/to/sdk")
if sdk is not None:
    platform = sdk.platform_jar(35)
    compiler = sdk.build_tool("d8", version="35.0.1")
```

`inspect_android()` accepts any existing SDK directory without requiring
Google's installer CLI, package records, or adb. Missing directories return
`None`; existing empty directories return a view whose individual package
lookups report missing files. adb/emulator paths are computed, not validated.
`tools` is `None` when no installer is present, and requesting its executable
raises a diagnostic. `find_android()` retains its installer-discovery contract.
Neither API downloads, launches tools, or changes the filesystem.

## Checksum-locked artifacts

```python
from sdkmgr import Artifact, ArtifactCache

artifact = Artifact(url, sha256, "library.jar")
cache = ArtifactCache(cache_directory)
path = cache.find(artifact)  # Read-only; None when absent.
path = cache.ensure(artifact)
```

Callers supply an HTTPS URL, a lowercase SHA-256 digest, and a portable single
filename. Files are stored under `artifacts/DIGEST/FILENAME`; cached bytes are
rehash-verified instead of trusting a sidecar. Downloads have size/time bounds,
installation locks, and atomic publication. Failed downloads are discarded;
corrupt entries require explicit repair and are not overwritten. This is not
a hostile-local-user cache boundary: keep the cache owned by the calling user.
Artifact selection, Maven coordinates, dependency graphs, lock-file formats,
archive interpretation, and classpaths remain the caller's responsibility.

## Development and verification

Python 3.12 or newer is required. Activate the environment after `uv sync`:

```sh
uv sync
source .venv/bin/activate
pytest
ruff check .
python scripts/check_release.py
```

The default tests use simulated SDKs and installers. They do not install
packages, ask for passwords, or depend on installed Java, mise, or nvm versions.
The CI configuration covers Python 3.12 and 3.14 on Linux, macOS, and Windows.
The release check builds both distributions in temporary storage, audits their
contents, installs each into a fresh environment, and runs the packaged tests
outside the checkout. It does not publish anything. `uv build` creates the
wheel and source archive in `dist/` when you are ready to retain artifacts.
The Python 3.12/3.14 tests and installed-artifact checks pass on all three
systems. Real mise/Node smokes pass on Linux, macOS, and Windows; real nvm/Node
smokes pass on Linux and macOS. Real OS JDK installation still needs
verification on those platforms.
Do not run an automatic source formatter.

Separate CI jobs exercise real mise bootstrap and Node installation on Linux,
macOS, and Windows, plus Unix nvm on Linux and macOS. These networked smoke
tests are outside the default pytest run:

```sh
python scripts/smoke_node.py mise
python scripts/smoke_node.py nvm
```

The mise smoke uses temporary storage. The nvm smoke installs into the detected
nvm store: use a disposable `NVM_DIR` when testing missing versions. Native
nvm-windows installation still needs a real Windows smoke test.

Additional opt-in smokes cover the new tools:

```sh
python scripts/smoke_tools.py clang --sanitizer address
python scripts/smoke_tools.py tailwindcss
python scripts/smoke_browser.py
```

The Clang smoke requires an existing compiler and never installs OS packages.
The Tailwind smoke uses a temporary cache, or reuses an exact-version tool
already on PATH. Both clean up their temporary files. Real Clang linking and
Tailwind download, reuse, and CSS generation pass on Linux, macOS, and Windows.
Clang installation through OS package managers still needs live validation.
The browser smoke uses a temporary pinned installation and profile, then
checks headless JavaScript rendering. `--root` can reuse a cache. Linux hosts
must allow Chrome's sandbox; the script never changes host security settings.
Use `--browser-argument=FLAG` only for explicitly chosen test launch options.
The `tool-provider` CI job runs these smokes on all three operating systems
when the Tests workflow is dispatched manually; it does not run on every push.
Its Linux browser smoke explicitly disables the sandbox only for the disposable
local fixture; sdkmgr discovery and installation never alter sandbox settings.
