Skip to content

Commit abac2bb

Browse files
authored
feat: make installer script the primary quickstart path (#387)
Promote uv-based scripts/install.sh to primary install path; probe Linux Chromium shared libs before download with exact remedy; clearer offline/proxy guidance; README and tests.
1 parent 583041f commit abac2bb

4 files changed

Lines changed: 269 additions & 13 deletions

File tree

README.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,26 @@ rounds against the wrong-target check.
4242
## Try it
4343

4444
The canonical first run uses the [OpenAdapt](https://github.com/OpenAdaptAI/openadapt)
45-
launcher:
45+
launcher. The installer handles Python versions, virtual environments, and
46+
shell quoting for you:
47+
48+
```bash
49+
curl -fsSL https://raw.githubusercontent.com/OpenAdaptAI/openadapt-flow/main/scripts/install.sh | sh
50+
51+
openadapt quickstart # the whole loop, VERIFIED
52+
```
53+
54+
Prefer plain pip? Two commands (quote the brackets; on Windows `cmd.exe` use
55+
double quotes: `pip install "openadapt[browser]"`):
4656

4757
```bash
4858
pip install 'openadapt[browser]'
4959

5060
openadapt quickstart # the whole loop, VERIFIED
5161
```
5262

53-
On Windows `cmd.exe`, use double quotes: `pip install "openadapt[browser]"`.
63+
**Requirements:** Python 3.10–3.12 (3.13+ is not yet supported; the installer
64+
provisions a suitable interpreter for you).
5465

5566
To work against this engine directly, install it and run the same loop under
5667
its engine-native name:

openadapt_flow/_browser_setup.py

Lines changed: 98 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
* **Idempotent across processes.** ``playwright install chromium`` is itself
1717
idempotent, and the probe skips it entirely once the binary is present, so a
1818
second *run* finds it installed and pays nothing.
19+
* **No wasted downloads on fresh Linux machines.** Before downloading on
20+
Linux, a cheap probe checks for the shared libraries Chromium needs; when
21+
any are missing, the exact remedy is printed and the launch aborts cleanly
22+
instead of downloading a browser that could not start anyway.
1923
* **Opt-out for air-gapped / pre-provisioned environments.** Set
2024
``OPENADAPT_FLOW_NO_AUTO_INSTALL=1`` to skip the auto-install; the original
2125
clear Playwright "Executable doesn't exist ... run playwright install" error
@@ -24,6 +28,7 @@
2428

2529
from __future__ import annotations
2630

31+
import ctypes.util
2732
import importlib.util
2833
import os
2934
import re
@@ -38,6 +43,29 @@
3843

3944
_NOTICE = "Downloading the Chromium browser OpenAdapt needs (first run only)…"
4045

46+
#: Shared-library soname bases Playwright's Chromium needs at launch time on
47+
#: Linux. These mirror the packages ``playwright install-deps chromium``
48+
#: installs (NSS, ATK, X11 helpers, audio, GBM, …). Names are the
49+
#: ``ctypes.util.find_library`` form: no ``lib`` prefix, no version suffix.
50+
_LINUX_CHROMIUM_SONAMES = (
51+
"nss3",
52+
"nspr4",
53+
"atk-1.0",
54+
"atk-bridge-2.0",
55+
"atspi",
56+
"cups",
57+
"drm",
58+
"xkbcommon",
59+
"xcomposite",
60+
"xdamage",
61+
"xfixes",
62+
"xrandr",
63+
"gbm",
64+
"pango-1.0",
65+
"cairo",
66+
"asound",
67+
)
68+
4169

4270
class BrowserSupportMissing(RuntimeError):
4371
"""The optional Playwright driver is absent for a browser operation."""
@@ -73,6 +101,53 @@ def _opted_out() -> bool:
73101
return bool(os.environ.get(NO_AUTO_INSTALL_ENV))
74102

75103

104+
#: The Debian/Ubuntu package names matching :data:`_LINUX_CHROMIUM_SONAMES`,
105+
#: shown as the manual alternative to ``playwright install-deps``.
106+
_LINUX_APT_PACKAGES = (
107+
"libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libatspi2.0-0 "
108+
"libcups2 libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 "
109+
"libxfixes3 libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2"
110+
)
111+
112+
113+
def _missing_chromium_system_libs() -> list[str]:
114+
"""Return the Chromium shared libraries missing on this Linux machine.
115+
116+
Uses ``ctypes.util.find_library`` (an ``ldconfig``-based lookup: cheap,
117+
offline, and no subprocess spawned by us). Returns an empty list on
118+
non-Linux platforms, where Playwright ships everything Chromium needs.
119+
"""
120+
if sys.platform != "linux":
121+
return []
122+
return [
123+
soname
124+
for soname in _LINUX_CHROMIUM_SONAMES
125+
if ctypes.util.find_library(soname) is None
126+
]
127+
128+
129+
def _require_linux_system_libs() -> None:
130+
"""Refuse to download Chromium when its system libraries cannot exist.
131+
132+
Fresh Linux machines without the X11/audio/NSS stack used to download the
133+
whole browser and only then fail at launch. When libraries are missing,
134+
print the exact remedy FIRST and abort cleanly before any download.
135+
"""
136+
missing = _missing_chromium_system_libs()
137+
if not missing:
138+
return
139+
libs = ", ".join(missing)
140+
raise RuntimeError(
141+
"Chromium cannot launch on this machine yet: required system "
142+
f"libraries are missing ({libs}).\n\n"
143+
"Install them once with:\n\n"
144+
" sudo python -m playwright install-deps chromium\n\n"
145+
"or, on Debian/Ubuntu:\n\n"
146+
f" sudo apt-get install -y {_LINUX_APT_PACKAGES}\n\n"
147+
"Then run your command again. Nothing was downloaded."
148+
)
149+
150+
76151
def _chromium_present() -> bool:
77152
"""Return whether Playwright's Chromium browser binary is installed.
78153
@@ -109,11 +184,18 @@ def _chromium_present() -> bool:
109184
def _install_chromium() -> None:
110185
"""Run ``python -m playwright install chromium`` once, with a notice.
111186
187+
On Linux, verifies first that Chromium's shared libraries are present and
188+
aborts with the exact remedy when they are not, so no download is wasted
189+
on a browser that could not launch.
190+
112191
Raises:
113-
RuntimeError: if the install subprocess fails (e.g. offline), with an
114-
actionable message pointing at the manual command and the opt-out.
192+
RuntimeError: if system libraries are missing (Linux), or if the
193+
install subprocess fails (e.g. offline or behind a proxy that
194+
blocks the Playwright CDN), with an actionable message pointing
195+
at the manual command, the proxy variable, and the opt-out.
115196
"""
116197
require_browser_support()
198+
_require_linux_system_libs()
117199
print(_NOTICE, file=sys.stderr, flush=True)
118200
try:
119201
subprocess.run(
@@ -123,11 +205,16 @@ def _install_chromium() -> None:
123205
except (subprocess.CalledProcessError, OSError) as exc:
124206
raise RuntimeError(
125207
"openadapt-flow could not automatically download the Chromium "
126-
"browser it needs. Run\n\n"
208+
"browser it needs. To install it manually, run:\n\n"
127209
" playwright install chromium\n\n"
128-
"manually (you may be offline or behind a proxy), or set "
129-
f"{NO_AUTO_INSTALL_ENV}=1 to disable auto-install if the browser "
130-
"is provisioned another way."
210+
"If you are behind a corporate proxy or firewall that blocks the "
211+
"Playwright download CDN, set HTTPS_PROXY first "
212+
"(for example: export HTTPS_PROXY=http://proxy.example.com:8080) "
213+
"and retry. If you are fully offline, install the browser on a "
214+
"connected machine and copy Playwright's cache directory "
215+
"(~/.cache/ms-playwright), or provision it another way. You can "
216+
f"also set {NO_AUTO_INSTALL_ENV}=1 to disable auto-install "
217+
"entirely."
131218
) from exc
132219

133220

@@ -139,10 +226,11 @@ def ensure_chromium_installed() -> None:
139226
(subsequent calls return immediately) and is a cheap no-op when the browser
140227
is already installed.
141228
142-
When the browser is missing it downloads it once via
143-
``playwright install chromium`` and prints a one-time notice. When
144-
:data:`NO_AUTO_INSTALL_ENV` is set it does nothing, leaving Playwright's own
145-
"browser not installed" error to surface at launch.
229+
When the browser is missing it verifies Chromium's system libraries
230+
(Linux), then downloads it once via ``playwright install chromium`` and
231+
prints a one-time notice. When :data:`NO_AUTO_INSTALL_ENV` is set it does
232+
nothing, leaving Playwright's own "browser not installed" error to surface
233+
at launch.
146234
"""
147235
global _ensured
148236
require_browser_support()

scripts/install.sh

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
#!/bin/sh
2+
# OpenAdapt installer (browser quickstart) — https://github.com/OpenAdaptAI/openadapt-flow
3+
#
4+
# curl -fsSL https://raw.githubusercontent.com/OpenAdaptAI/openadapt-flow/main/scripts/install.sh | sh
5+
#
6+
# Installs uv (a fast Python toolchain) if you don't have it, provisions
7+
# Python 3.12 (downloading a managed interpreter only if no suitable system
8+
# Python exists — 3.13+ is not supported yet), installs OpenAdapt with browser
9+
# support as a persistent `openadapt` command, and finishes with a short
10+
# environment check.
11+
#
12+
# Safe to re-run: it upgrades in place. Nothing runs with elevated privileges;
13+
# read the script first if you like — that's why it's served in the clear over
14+
# HTTPS.
15+
set -eu
16+
17+
info() { printf '\033[1;36m==>\033[0m %s\n' "$1"; }
18+
err() { printf '\033[1;31mError:\033[0m %s\n' "$1" >&2; }
19+
20+
if ! command -v curl >/dev/null 2>&1; then
21+
err "curl is required but not installed."
22+
exit 1
23+
fi
24+
25+
PYTHON_VERSION="3.12"
26+
27+
if ! command -v uv >/dev/null 2>&1; then
28+
info "Installing uv (fast Python package manager)…"
29+
curl -LsSf https://astral.sh/uv/install.sh | sh
30+
# uv installs to ~/.local/bin by default; make it visible to this script.
31+
export PATH="$HOME/.local/bin:$PATH"
32+
fi
33+
34+
if ! command -v uv >/dev/null 2>&1; then
35+
err "uv was installed but isn't on your PATH yet."
36+
err "Open a new terminal and re-run this command, or add \$HOME/.local/bin to PATH."
37+
exit 1
38+
fi
39+
40+
# The square brackets in 'openadapt[browser]' are glob characters in many
41+
# shells — installing from here means nobody has to quote them by hand.
42+
info "Installing OpenAdapt with browser support…"
43+
uv tool install --upgrade --python "$PYTHON_VERSION" 'openadapt[browser]'
44+
45+
# Make sure the installed `openadapt` command is on PATH in future shells.
46+
uv tool update-shell >/dev/null 2>&1 || true
47+
export PATH="$HOME/.local/bin:$PATH"
48+
49+
# ---- environment check ----------------------------------------------------
50+
os="$(uname -s)"
51+
arch="$(uname -m 2>/dev/null || echo unknown)"
52+
python_status="not found"
53+
python_bin="$(uv python find "$PYTHON_VERSION" 2>/dev/null || true)"
54+
if [ -n "$python_bin" ]; then
55+
python_status="$("$python_bin" --version 2>/dev/null || echo "$PYTHON_VERSION") at $python_bin"
56+
fi
57+
command_path="$(command -v openadapt 2>/dev/null || echo "not on PATH yet — open a new terminal first")"
58+
59+
printf '\n'
60+
info "Environment"
61+
printf ' OS: %s (%s)\n' "$os" "$arch"
62+
printf ' Python: %s\n' "$python_status"
63+
printf ' Command: %s\n' "$command_path"
64+
printf ' Browser: Chromium provisions automatically on first browser use;\n'
65+
printf ' nothing was downloaded during this install.\n'
66+
67+
info "OpenAdapt is installed. Run your first workflow:"
68+
printf '\n openadapt quickstart\n\n'
69+
info "If your shell can't find it yet, open a new terminal first."

tests/test_browser_setup.py

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
even across repeated calls),
99
* the ``OPENADAPT_FLOW_NO_AUTO_INSTALL`` opt-out skips the install entirely,
1010
* a failing install surfaces an actionable error,
11-
* importing the package triggers no install (import stays side-effect-free).
11+
* importing the package triggers no install (import stays side-effect-free),
12+
* on Linux, missing Chromium system libraries abort BEFORE any download with
13+
the exact remedy (probes are monkeypatched; no network, no host state).
1214
"""
1315

1416
from __future__ import annotations
@@ -126,6 +128,7 @@ def test_missing_browser_extra_refuses_before_network_or_subprocess(monkeypatch)
126128
def test_installs_once_when_missing(monkeypatch):
127129
"""Missing browser -> install runs exactly once, even on repeat calls."""
128130
monkeypatch.setattr(bs, "_chromium_present", lambda: False)
131+
monkeypatch.setattr(bs, "_missing_chromium_system_libs", lambda: [])
129132
calls = []
130133

131134
def fake_run(cmd, *a, **k):
@@ -166,6 +169,7 @@ def _boom():
166169
def test_failed_install_raises_actionable_error(monkeypatch):
167170
"""A failing install subprocess surfaces a clear, actionable RuntimeError."""
168171
monkeypatch.setattr(bs, "_chromium_present", lambda: False)
172+
monkeypatch.setattr(bs, "_missing_chromium_system_libs", lambda: [])
169173

170174
def fake_run(cmd, *a, **k):
171175
raise subprocess.CalledProcessError(1, cmd)
@@ -178,6 +182,8 @@ def fake_run(cmd, *a, **k):
178182
msg = str(exc.value)
179183
assert "playwright install chromium" in msg
180184
assert bs.NO_AUTO_INSTALL_ENV in msg
185+
# Proxy guidance for CDN-blocked / offline machines.
186+
assert "HTTPS_PROXY" in msg
181187

182188

183189
def test_probe_failure_falls_back_to_install(monkeypatch):
@@ -187,6 +193,7 @@ def _raise():
187193
raise RuntimeError("driver blew up")
188194

189195
monkeypatch.setattr(bs, "_chromium_present", _raise)
196+
monkeypatch.setattr(bs, "_missing_chromium_system_libs", lambda: [])
190197
calls = []
191198
monkeypatch.setattr(subprocess, "run", lambda cmd, *a, **k: calls.append(cmd))
192199

@@ -203,3 +210,84 @@ def test_import_is_side_effect_free(monkeypatch):
203210
importlib.reload(importlib.import_module("openadapt_flow"))
204211

205212
assert called == []
213+
214+
215+
# --- Linux shared-library gate ---------------------------------------------
216+
217+
218+
def test_lib_probe_is_empty_off_linux(monkeypatch):
219+
"""Non-Linux platforms never report missing libraries."""
220+
monkeypatch.setattr(bs.sys, "platform", "darwin")
221+
222+
def _boom(name):
223+
raise AssertionError("find_library must not run off Linux")
224+
225+
monkeypatch.setattr(bs.ctypes.util, "find_library", _boom)
226+
227+
assert bs._missing_chromium_system_libs() == []
228+
229+
230+
def test_lib_probe_reports_only_missing_sonames(monkeypatch):
231+
"""On Linux, exactly the sonames find_library cannot resolve are listed."""
232+
monkeypatch.setattr(bs.sys, "platform", "linux")
233+
present = {"nss3", "gbm"}
234+
235+
def fake_find_library(name):
236+
return "lib{}.so.9".format(name) if name in present else None
237+
238+
monkeypatch.setattr(bs.ctypes.util, "find_library", fake_find_library)
239+
240+
missing = bs._missing_chromium_system_libs()
241+
242+
assert set(missing) == set(bs._LINUX_CHROMIUM_SONAMES) - present
243+
# Deterministic order for stable error messages.
244+
assert missing == [s for s in bs._LINUX_CHROMIUM_SONAMES if s not in present]
245+
246+
247+
def test_missing_system_libs_abort_before_any_download(monkeypatch):
248+
"""Missing libraries -> remedy raised and NO download is attempted."""
249+
monkeypatch.setattr(bs, "_missing_chromium_system_libs", lambda: ["nss3", "gbm"])
250+
# Presence is checked before the library gate; report "missing" so the
251+
# install path (and therefore the gate) is reached.
252+
monkeypatch.setattr(bs, "_chromium_present", lambda: False)
253+
calls = []
254+
monkeypatch.setattr(subprocess, "run", lambda *a, **k: calls.append((a, k)))
255+
256+
with pytest.raises(RuntimeError) as exc:
257+
bs.ensure_chromium_installed()
258+
259+
msg = str(exc.value)
260+
assert "nss3" in msg
261+
assert "playwright install-deps chromium" in msg # exact primary remedy
262+
assert "apt-get install" in msg # apt alternative line
263+
assert "Nothing was downloaded" in msg
264+
assert calls == []
265+
266+
267+
def test_present_system_libs_do_not_block_install(monkeypatch):
268+
"""Empty probe result -> the normal download path proceeds unchanged."""
269+
monkeypatch.setattr(bs, "_chromium_present", lambda: False)
270+
monkeypatch.setattr(bs, "_missing_chromium_system_libs", lambda: [])
271+
calls = []
272+
monkeypatch.setattr(subprocess, "run", lambda cmd, *a, **k: calls.append(cmd))
273+
274+
bs.ensure_chromium_installed()
275+
276+
assert len(calls) == 1
277+
assert calls[0][1:] == ["-m", "playwright", "install", "chromium"]
278+
279+
280+
def test_opt_out_bypasses_the_library_gate(monkeypatch):
281+
"""OPENADAPT_FLOW_NO_AUTO_INSTALL skips both the lib probe and download."""
282+
monkeypatch.setenv(bs.NO_AUTO_INSTALL_ENV, "1")
283+
284+
def _boom():
285+
raise AssertionError("probe must not run when opted out")
286+
287+
monkeypatch.setattr(bs, "_missing_chromium_system_libs", _boom)
288+
calls = []
289+
monkeypatch.setattr(subprocess, "run", lambda *a, **k: calls.append((a, k)))
290+
291+
bs.ensure_chromium_installed()
292+
293+
assert calls == []

0 commit comments

Comments
 (0)