-
Notifications
You must be signed in to change notification settings - Fork 619
Expand file tree
/
Copy pathadmin.py
More file actions
625 lines (533 loc) · 23.8 KB
/
admin.py
File metadata and controls
625 lines (533 loc) · 23.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
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