browser-use--browser-harness
626 行
24 KiB
Python
626 行
24 KiB
Python
import json
|
|
import os
|
|
import socket
|
|
import time
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
|
|
def _load_env():
|
|
p = Path(__file__).parent / ".env"
|
|
if not p.exists():
|
|
return
|
|
for line in p.read_text().splitlines():
|
|
line = line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
k, v = line.split("=", 1)
|
|
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
|
|
|
|
|
|
_load_env()
|
|
|
|
NAME = os.environ.get("BU_NAME", "default")
|
|
BU_API = "https://api.browser-use.com/api/v3"
|
|
GH_RELEASES = "https://api.github.com/repos/browser-use/browser-harness/releases/latest"
|
|
VERSION_CACHE = Path("/tmp/bu-version-cache.json")
|
|
VERSION_CACHE_TTL = 24 * 3600
|
|
|
|
|
|
def _paths(name):
|
|
n = name or NAME
|
|
return f"/tmp/bu-{n}.sock", f"/tmp/bu-{n}.pid"
|
|
|
|
|
|
def _log_tail(name):
|
|
p = f"/tmp/bu-{name or NAME}.log"
|
|
try:
|
|
return Path(p).read_text().strip().splitlines()[-1]
|
|
except (FileNotFoundError, IndexError):
|
|
return None
|
|
|
|
|
|
def _needs_chrome_remote_debugging_prompt(msg):
|
|
"""True when Chrome needs the inspect-page permission/profile flow."""
|
|
lower = (msg or "").lower()
|
|
return (
|
|
"devtoolsactiveport not found" in lower
|
|
or "enable chrome://inspect" in lower
|
|
or "not live yet" in lower
|
|
or (
|
|
"ws handshake failed" in lower
|
|
and (
|
|
"403" in lower
|
|
or "opening handshake" in lower
|
|
or "timed out" in lower
|
|
or "timeout" in lower
|
|
)
|
|
)
|
|
)
|
|
|
|
|
|
def _is_local_chrome_mode(env=None):
|
|
"""True when the daemon discovers a local Chrome instead of a remote CDP WS."""
|
|
return not (env or {}).get("BU_CDP_WS") and not os.environ.get("BU_CDP_WS")
|
|
|
|
|
|
def daemon_alive(name=None):
|
|
try:
|
|
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
s.settimeout(1)
|
|
s.connect(_paths(name)[0])
|
|
s.close()
|
|
return True
|
|
except (FileNotFoundError, ConnectionRefusedError, socket.timeout):
|
|
return False
|
|
|
|
|
|
def ensure_daemon(wait=60.0, name=None, env=None):
|
|
"""Idempotent. Self-heals stale daemon, cold Chrome, and missing Allow on chrome://inspect."""
|
|
if daemon_alive(name):
|
|
# Stale daemons accept connects AND reply to meta:* (pure Python) even when the
|
|
# CDP WS to Chrome is dead — probe with a real CDP call and require "result".
|
|
try:
|
|
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM); s.settimeout(3)
|
|
s.connect(_paths(name)[0])
|
|
s.sendall(b'{"method":"Target.getTargets","params":{}}\n')
|
|
data = b""
|
|
while not data.endswith(b"\n"):
|
|
chunk = s.recv(1 << 16)
|
|
if not chunk: break
|
|
data += chunk
|
|
if b'"result"' in data: return
|
|
except Exception: pass
|
|
restart_daemon(name)
|
|
|
|
import subprocess, sys
|
|
local = _is_local_chrome_mode(env)
|
|
for attempt in (0, 1):
|
|
e = {**os.environ, **({"BU_NAME": name} if name else {}), **(env or {})}
|
|
p = subprocess.Popen(
|
|
["uv", "run", "daemon.py"],
|
|
cwd=os.path.dirname(os.path.abspath(__file__)),
|
|
env=e, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True,
|
|
)
|
|
deadline = time.time() + wait
|
|
while time.time() < deadline:
|
|
if daemon_alive(name): return
|
|
if p.poll() is not None: break
|
|
time.sleep(0.2)
|
|
msg = _log_tail(name) or ""
|
|
if local and attempt == 0 and _needs_chrome_remote_debugging_prompt(msg):
|
|
_open_chrome_inspect()
|
|
print("browser-harness: click Allow on chrome://inspect (and tick the checkbox if shown)", file=sys.stderr)
|
|
restart_daemon(name)
|
|
continue
|
|
raise RuntimeError(msg or f"daemon {name or NAME} didn't come up -- check /tmp/bu-{name or NAME}.log")
|
|
|
|
|
|
def stop_remote_daemon(name="remote"):
|
|
"""Stop a remote daemon and its backing Browser Use cloud browser.
|
|
|
|
Triggers the daemon's clean shutdown, which PATCHes
|
|
/browsers/{id} {"action":"stop"} so billing ends and any profile
|
|
state in the session is persisted."""
|
|
# restart_daemon is misnamed — it only stops the daemon (sends
|
|
# shutdown, SIGTERMs if needed, unlinks socket+pid). It never
|
|
# restarts anything on its own; a follow-up `browser-harness`
|
|
# call would auto-spawn a fresh one via ensure_daemon(). That
|
|
# "run-it-again-to-restart" workflow is why it was named that way.
|
|
restart_daemon(name)
|
|
|
|
|
|
def restart_daemon(name=None):
|
|
"""Best-effort daemon shutdown + socket/pid cleanup.
|
|
|
|
Name is historical: callers typically follow this with another
|
|
`browser-harness` invocation, which auto-spawns a fresh daemon via
|
|
ensure_daemon(). The function itself only stops."""
|
|
import signal
|
|
|
|
sock, pid_path = _paths(name)
|
|
try:
|
|
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
s.settimeout(5)
|
|
s.connect(sock)
|
|
s.sendall(b'{"meta":"shutdown"}\n')
|
|
s.recv(1024)
|
|
s.close()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
pid = int(open(pid_path).read())
|
|
except (FileNotFoundError, ValueError):
|
|
pid = None
|
|
if pid:
|
|
for _ in range(75):
|
|
try:
|
|
os.kill(pid, 0)
|
|
time.sleep(0.2)
|
|
except ProcessLookupError:
|
|
break
|
|
else:
|
|
try:
|
|
os.kill(pid, signal.SIGTERM)
|
|
except ProcessLookupError:
|
|
pass
|
|
for f in (sock, pid_path):
|
|
try:
|
|
os.unlink(f)
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
|
|
def _browser_use(path, method, body=None):
|
|
key = os.environ.get("BROWSER_USE_API_KEY")
|
|
if not key:
|
|
raise RuntimeError("BROWSER_USE_API_KEY missing -- see .env.example")
|
|
req = urllib.request.Request(
|
|
f"{BU_API}{path}",
|
|
method=method,
|
|
data=(json.dumps(body).encode() if body is not None else None),
|
|
headers={"X-Browser-Use-API-Key": key, "Content-Type": "application/json"},
|
|
)
|
|
return json.loads(urllib.request.urlopen(req, timeout=60).read() or b"{}")
|
|
|
|
|
|
def _cdp_ws_from_url(cdp_url):
|
|
return json.loads(urllib.request.urlopen(f"{cdp_url}/json/version", timeout=15).read())["webSocketDebuggerUrl"]
|
|
|
|
|
|
def _has_local_gui():
|
|
"""True when this machine plausibly has a browser we can open. False on headless servers."""
|
|
import platform
|
|
system = platform.system()
|
|
if system in ("Darwin", "Windows"):
|
|
return True
|
|
if system == "Linux":
|
|
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
|
return False
|
|
|
|
|
|
def _show_live_url(url):
|
|
"""Print liveUrl and auto-open it locally if there's a GUI."""
|
|
import sys, webbrowser
|
|
if not url: return
|
|
print(url)
|
|
if not _has_local_gui():
|
|
print("(no local GUI — share the liveUrl with the user)", file=sys.stderr)
|
|
return
|
|
try:
|
|
webbrowser.open(url, new=2)
|
|
print("(opened liveUrl in your default browser)", file=sys.stderr)
|
|
except Exception as e:
|
|
print(f"(couldn't auto-open: {e} — share the liveUrl with the user)", file=sys.stderr)
|
|
|
|
|
|
def list_cloud_profiles():
|
|
"""List cloud profiles under the current API key.
|
|
|
|
Returns [{id, name, userId, cookieDomains, lastUsedAt}, ...]. `cookieDomains`
|
|
is the array of domain strings the cloud profile has cookies for — use
|
|
`len(cookieDomains)` as a cheap 'how much is logged in' summary. Per-cookie
|
|
detail on a *local* profile before sync: `profile-use inspect --profile <name>`.
|
|
|
|
Paginates through all pages — the API caps `pageSize` at 100."""
|
|
out, page = [], 1
|
|
while True:
|
|
listing = _browser_use(f"/profiles?pageSize=100&pageNumber={page}", "GET")
|
|
items = listing.get("items") if isinstance(listing, dict) else listing
|
|
if not items:
|
|
break
|
|
for p in items:
|
|
detail = _browser_use(f"/profiles/{p['id']}", "GET")
|
|
out.append({
|
|
"id": detail["id"],
|
|
"name": detail.get("name"),
|
|
"userId": detail.get("userId"),
|
|
"cookieDomains": detail.get("cookieDomains") or [],
|
|
"lastUsedAt": detail.get("lastUsedAt"),
|
|
})
|
|
if isinstance(listing, dict) and len(out) >= listing.get("totalItems", len(out)):
|
|
break
|
|
page += 1
|
|
return out
|
|
|
|
|
|
def _resolve_profile_name(profile_name):
|
|
"""Find a single cloud profile by exact name; raise if 0 or >1 match."""
|
|
matches = [p for p in list_cloud_profiles() if p.get("name") == profile_name]
|
|
if not matches:
|
|
raise RuntimeError(f"no cloud profile named {profile_name!r} -- call list_cloud_profiles() or sync_local_profile() first")
|
|
if len(matches) > 1:
|
|
raise RuntimeError(f"{len(matches)} cloud profiles named {profile_name!r} -- pass profileId=<uuid> instead")
|
|
return matches[0]["id"]
|
|
|
|
|
|
def start_remote_daemon(name="remote", profileName=None, **create_kwargs):
|
|
"""Provision a Browser Use cloud browser and start a daemon attached to it.
|
|
|
|
kwargs forwarded to `POST /browsers` (camelCase):
|
|
profileId — cloud profile UUID; start already-logged-in. Default: none (clean browser).
|
|
profileName — cloud profile name; resolved client-side to profileId via list_cloud_profiles().
|
|
proxyCountryCode — ISO2 country code (default "us"); pass None to disable the BU proxy.
|
|
timeout — minutes, 1..240.
|
|
customProxy — {host, port, username, password, ignoreCertErrors}.
|
|
browserScreenWidth / browserScreenHeight, allowResizing, enableRecording.
|
|
|
|
Returns the full browser dict including `liveUrl`. Prints the liveUrl and
|
|
auto-opens it locally when a GUI is detected, so the user can watch along."""
|
|
if daemon_alive(name):
|
|
raise RuntimeError(f"daemon {name!r} already alive -- restart_daemon({name!r}) first")
|
|
if profileName:
|
|
if "profileId" in create_kwargs:
|
|
raise RuntimeError("pass profileName OR profileId, not both")
|
|
create_kwargs["profileId"] = _resolve_profile_name(profileName)
|
|
browser = _browser_use("/browsers", "POST", create_kwargs)
|
|
ensure_daemon(
|
|
name=name,
|
|
env={"BU_CDP_WS": _cdp_ws_from_url(browser["cdpUrl"]), "BU_BROWSER_ID": browser["id"]},
|
|
)
|
|
_show_live_url(browser.get("liveUrl"))
|
|
return browser
|
|
|
|
|
|
def list_local_profiles():
|
|
"""Detected local browser profiles on this machine. Shells out to `profile-use list --json`.
|
|
Returns [{BrowserName, BrowserPath, ProfileName, ProfilePath, DisplayName}, ...].
|
|
Requires `profile-use` (see interaction-skills/profile-sync.md for install)."""
|
|
import json, shutil, subprocess
|
|
if not shutil.which("profile-use"):
|
|
raise RuntimeError("profile-use not installed -- curl -fsSL https://browser-use.com/profile.sh | sh")
|
|
return json.loads(subprocess.check_output(["profile-use", "list", "--json"], text=True))
|
|
|
|
|
|
def sync_local_profile(profile_name, browser=None, cloud_profile_id=None,
|
|
include_domains=None, exclude_domains=None):
|
|
"""Sync a local profile's cookies to a cloud profile. Returns the cloud UUID.
|
|
|
|
Shells out to `profile-use sync` (v1.0.4+). Requires BROWSER_USE_API_KEY and the
|
|
target local Chrome profile to be closed (profile-use needs an exclusive lock on
|
|
the Cookies DB).
|
|
|
|
Args:
|
|
profile_name: local Chrome profile name (as shown by `list_local_profiles`).
|
|
browser: disambiguate when multiple browsers have profiles of the
|
|
same name (e.g. "Google Chrome"). Default: any match.
|
|
cloud_profile_id: push cookies into this existing cloud profile instead of
|
|
creating a new one. Idempotent — call again to refresh
|
|
the same profile. Default: create new.
|
|
include_domains: only sync cookies for these domains (and subdomains).
|
|
Leading dot is optional. Example: ["google.com", "stripe.com"].
|
|
exclude_domains: drop cookies for these domains (and subdomains). Applied
|
|
before `include_domains` so exclude wins on overlap."""
|
|
import os, re, shutil, subprocess, sys
|
|
if not shutil.which("profile-use"):
|
|
raise RuntimeError("profile-use not installed -- curl -fsSL https://browser-use.com/profile.sh | sh")
|
|
if not os.environ.get("BROWSER_USE_API_KEY"):
|
|
raise RuntimeError("BROWSER_USE_API_KEY missing")
|
|
cmd = ["profile-use", "sync", "--profile", profile_name]
|
|
if browser:
|
|
cmd += ["--browser", browser]
|
|
if cloud_profile_id:
|
|
cmd += ["--cloud-profile-id", cloud_profile_id]
|
|
for d in include_domains or []:
|
|
cmd += ["--domain", d]
|
|
for d in exclude_domains or []:
|
|
cmd += ["--exclude-domain", d]
|
|
r = subprocess.run(cmd, text=True, capture_output=True)
|
|
sys.stdout.write(r.stdout)
|
|
sys.stderr.write(r.stderr)
|
|
if r.returncode != 0:
|
|
raise RuntimeError(f"profile-use sync failed (exit {r.returncode})")
|
|
# With --cloud-profile-id the tool prints "♻️ Using existing cloud profile"
|
|
# instead of "Profile created: <uuid>", so we already know the UUID.
|
|
if cloud_profile_id:
|
|
return cloud_profile_id
|
|
m = re.search(r"Profile created:\s+([0-9a-f-]{36})", r.stdout)
|
|
if not m:
|
|
raise RuntimeError(f"profile-use did not report a profile UUID (exit {r.returncode})")
|
|
return m.group(1)
|
|
|
|
|
|
def _version():
|
|
"""Installed version of the browser-harness package. Empty string if unknown."""
|
|
try:
|
|
from importlib.metadata import PackageNotFoundError, version
|
|
try:
|
|
return version("browser-harness")
|
|
except PackageNotFoundError:
|
|
return ""
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def _repo_dir():
|
|
"""Return the repo root if this install is an editable git clone, else None."""
|
|
p = Path(__file__).resolve().parent
|
|
return p if (p / ".git").is_dir() else None
|
|
|
|
|
|
def _install_mode():
|
|
""""git" for editable clone, "pypi" for an installed wheel, "unknown" otherwise."""
|
|
if _repo_dir():
|
|
return "git"
|
|
return "pypi" if _version() else "unknown"
|
|
|
|
|
|
def _cache_read():
|
|
try:
|
|
return json.loads(VERSION_CACHE.read_text())
|
|
except (FileNotFoundError, ValueError):
|
|
return {}
|
|
|
|
|
|
def _cache_write(data):
|
|
try:
|
|
VERSION_CACHE.write_text(json.dumps(data))
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _latest_release_tag(force=False):
|
|
"""Return latest release tag from GitHub, or None. Cached for 24h to avoid hammering the API."""
|
|
cache = _cache_read()
|
|
now = time.time()
|
|
if not force and cache.get("tag") and now - cache.get("fetched_at", 0) < VERSION_CACHE_TTL:
|
|
return cache["tag"]
|
|
try:
|
|
req = urllib.request.Request(GH_RELEASES, headers={"Accept": "application/vnd.github+json"})
|
|
tag = json.loads(urllib.request.urlopen(req, timeout=5).read()).get("tag_name") or ""
|
|
except Exception:
|
|
return cache.get("tag") # fall back to last known
|
|
tag = tag.lstrip("v")
|
|
_cache_write({**cache, "tag": tag, "fetched_at": now})
|
|
return tag or None
|
|
|
|
|
|
def _version_tuple(v):
|
|
"""Best-effort semver parse. Non-numeric components sort as 0, so pre-releases may not rank perfectly."""
|
|
parts = []
|
|
for s in (v or "").split("."):
|
|
m = ""
|
|
for ch in s:
|
|
if ch.isdigit():
|
|
m += ch
|
|
else:
|
|
break
|
|
parts.append(int(m) if m else 0)
|
|
return tuple(parts)
|
|
|
|
|
|
def check_for_update():
|
|
"""(current, latest, newer_available). latest may be None if the API was unreachable and no cache exists."""
|
|
cur = _version()
|
|
latest = _latest_release_tag()
|
|
newer = bool(cur and latest and _version_tuple(latest) > _version_tuple(cur))
|
|
return cur, latest, newer
|
|
|
|
|
|
def print_update_banner(out=None):
|
|
"""Print the update banner to stderr once per day. Silent when up-to-date or offline."""
|
|
import sys
|
|
out = out or sys.stderr
|
|
cache = _cache_read()
|
|
today = time.strftime("%Y-%m-%d")
|
|
if cache.get("banner_shown_on") == today:
|
|
return
|
|
cur, latest, newer = check_for_update()
|
|
if not newer:
|
|
return
|
|
print(f"[browser-harness] update available: {cur} -> {latest}", file=out)
|
|
print(f"[browser-harness] agents: run `browser-harness --update -y` to upgrade and restart the daemon", file=out)
|
|
_cache_write({**cache, "banner_shown_on": today})
|
|
|
|
|
|
def _chrome_running():
|
|
"""Cross-platform best-effort check for a running Chrome/Edge process."""
|
|
import platform, subprocess
|
|
system = platform.system()
|
|
try:
|
|
if system == "Windows":
|
|
out = subprocess.check_output(["tasklist"], text=True, timeout=5)
|
|
names = ("chrome.exe", "msedge.exe")
|
|
else:
|
|
out = subprocess.check_output(["ps", "-A", "-o", "comm="], text=True, timeout=5)
|
|
names = ("Google Chrome", "chrome", "chromium", "Microsoft Edge", "msedge")
|
|
return any(n.lower() in out.lower() for n in names)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _open_chrome_inspect():
|
|
"""Open chrome://inspect/#remote-debugging so the user can tick the checkbox."""
|
|
import platform, subprocess, webbrowser
|
|
url = "chrome://inspect/#remote-debugging"
|
|
if platform.system() == "Darwin":
|
|
try:
|
|
subprocess.run([
|
|
"osascript",
|
|
"-e", 'tell application "Google Chrome" to activate',
|
|
"-e", f'tell application "Google Chrome" to open location "{url}"',
|
|
], timeout=5, check=False)
|
|
return
|
|
except Exception:
|
|
pass
|
|
try:
|
|
webbrowser.open(url, new=2)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def run_setup():
|
|
"""Interactive bootstrap: attach to the running browser, guiding the user through chrome://inspect if needed.
|
|
|
|
Exit code 0 on success, 1 on failure."""
|
|
import sys
|
|
print("browser-harness setup: attaching to your browser...")
|
|
|
|
if daemon_alive():
|
|
print("daemon already running; nothing to do.")
|
|
return 0
|
|
|
|
if not _chrome_running():
|
|
print("no Chrome/Edge process detected. please start your browser and rerun `browser-harness --setup`.")
|
|
return 1
|
|
|
|
# First attach attempt.
|
|
try:
|
|
ensure_daemon(wait=20.0)
|
|
print("daemon is up.")
|
|
return 0
|
|
except RuntimeError as e:
|
|
first_err = str(e)
|
|
|
|
needs_inspect = _is_local_chrome_mode() and _needs_chrome_remote_debugging_prompt(first_err)
|
|
if needs_inspect:
|
|
print("chrome remote-debugging is not enabled on the current profile.")
|
|
print("opening chrome://inspect/#remote-debugging -- in the tab that opens:")
|
|
print(" 1. if chrome shows the profile picker, pick your normal profile;")
|
|
print(" 2. tick 'Discover network targets' and click Allow if prompted.")
|
|
_open_chrome_inspect()
|
|
else:
|
|
print(f"attach failed: {first_err}")
|
|
print("retrying for up to 60s (chrome may still be starting up)...")
|
|
|
|
deadline = time.time() + 60
|
|
last = first_err
|
|
while time.time() < deadline:
|
|
try:
|
|
ensure_daemon(wait=5.0)
|
|
print("daemon is up.")
|
|
return 0
|
|
except RuntimeError as e:
|
|
last = str(e)
|
|
time.sleep(2)
|
|
|
|
print(f"setup failed: {last}", file=sys.stderr)
|
|
print("run `browser-harness --doctor` for diagnostics.", file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
def run_doctor():
|
|
"""Read-only diagnostics. Exit 0 iff everything looks healthy."""
|
|
import platform, shutil, sys
|
|
cur = _version()
|
|
mode = _install_mode()
|
|
chrome = _chrome_running()
|
|
daemon = daemon_alive()
|
|
profile_use = shutil.which("profile-use") is not None
|
|
api_key = bool(os.environ.get("BROWSER_USE_API_KEY"))
|
|
latest = _latest_release_tag()
|
|
# Only claim an update when we know the installed version — `cur or "(unknown)"`
|
|
# for display would otherwise be parsed as (0,) and flag every latest as newer.
|
|
newer = bool(cur and latest and _version_tuple(latest) > _version_tuple(cur))
|
|
cur_display = cur or "(unknown)"
|
|
|
|
def row(label, ok, detail=""):
|
|
mark = "ok " if ok else "FAIL"
|
|
print(f" [{mark}] {label}{(' — ' + detail) if detail else ''}")
|
|
|
|
print("browser-harness doctor")
|
|
print(f" platform {platform.system()} {platform.release()}")
|
|
print(f" python {sys.version.split()[0]}")
|
|
print(f" version {cur_display} ({mode})")
|
|
if latest:
|
|
print(f" latest release {latest}" + (" (update available)" if newer else ""))
|
|
else:
|
|
print(" latest release (could not reach github)")
|
|
row("chrome running", chrome, "" if chrome else "start chrome/edge and rerun `browser-harness --setup`")
|
|
row("daemon alive", daemon, "" if daemon else "run `browser-harness --setup` to attach")
|
|
row("profile-use installed", profile_use, "" if profile_use else "optional: curl -fsSL https://browser-use.com/profile.sh | sh")
|
|
row("BROWSER_USE_API_KEY set", api_key, "" if api_key else "optional: needed only for cloud browsers / profile sync")
|
|
# Core health = chrome + daemon. Profile-use/api-key are optional.
|
|
return 0 if (chrome and daemon) else 1
|
|
|
|
|
|
def _prompt_yes(question, default_yes=True, yes=False):
|
|
if yes:
|
|
return True
|
|
suffix = "[Y/n]" if default_yes else "[y/N]"
|
|
try:
|
|
ans = input(f"{question} {suffix} ").strip().lower()
|
|
except EOFError:
|
|
return default_yes
|
|
if not ans:
|
|
return default_yes
|
|
return ans.startswith("y")
|
|
|
|
|
|
def run_update(yes=False):
|
|
"""Pull the latest version and (after prompt) restart the daemon so it picks up changed code.
|
|
|
|
Exit 0 on success, non-zero on failure."""
|
|
import subprocess, sys
|
|
cur, latest, newer = check_for_update()
|
|
# Only short-circuit as "up to date" when we actually know the installed
|
|
# version. Otherwise `newer=False` just means "couldn't compare" — proceed.
|
|
if cur and latest and not newer:
|
|
print(f"browser-harness is up to date ({cur}).")
|
|
return 0
|
|
if cur and latest:
|
|
print(f"updating browser-harness: {cur} -> {latest}")
|
|
elif latest:
|
|
print(f"installed version unknown; will try to update to {latest}.")
|
|
else:
|
|
print("could not reach github; will try to update anyway.")
|
|
|
|
mode = _install_mode()
|
|
if mode == "git":
|
|
repo = _repo_dir()
|
|
status = subprocess.run(["git", "-C", str(repo), "status", "--porcelain"], capture_output=True, text=True)
|
|
if status.returncode != 0:
|
|
print(f"git status failed: {status.stderr.strip()}", file=sys.stderr)
|
|
return 1
|
|
if status.stdout.strip():
|
|
print(f"refusing to update: uncommitted changes in {repo}", file=sys.stderr)
|
|
print("commit or stash them first, or run `git -C %s pull` yourself." % repo, file=sys.stderr)
|
|
return 1
|
|
r = subprocess.run(["git", "-C", str(repo), "pull", "--ff-only"])
|
|
if r.returncode != 0:
|
|
return r.returncode
|
|
elif mode == "pypi":
|
|
tool_upgrade = subprocess.run(["uv", "tool", "upgrade", "browser-harness"])
|
|
if tool_upgrade.returncode != 0:
|
|
# Fall back to pip in case this wasn't a `uv tool install`.
|
|
pip = subprocess.run([sys.executable, "-m", "pip", "install", "--upgrade", "browser-harness"])
|
|
if pip.returncode != 0:
|
|
return pip.returncode
|
|
else:
|
|
print("unknown install mode; can't auto-update.", file=sys.stderr)
|
|
return 1
|
|
|
|
# Invalidate banner/tag cache so the new version doesn't keep nagging.
|
|
cache = _cache_read()
|
|
cache.pop("banner_shown_on", None)
|
|
_cache_write(cache)
|
|
|
|
if daemon_alive():
|
|
if _prompt_yes("restart the running daemon so it picks up the new code?", default_yes=True, yes=yes):
|
|
restart_daemon()
|
|
print("daemon stopped; it will auto-restart on next `browser-harness` call.")
|
|
else:
|
|
print("daemon left running on old code. run `browser-harness` and it'll use the new code after the daemon recycles.")
|
|
print("update complete.")
|
|
return 0
|