Files
LLeMbas/scripts/fetch_vendor.py
T
Jaroslav Beneš 47791a88c7 A terminal panel beside an agent chat
A real shell on the chat's own connection, opened and closed like the
inspector and never beside it. The modes govern the model; what a person
types is theirs, since they hold the credential and could open the same
shell with an ssh client. The model cannot see the panel -- a button
copies the output you choose into the composer.

The session outlives the socket: closing the panel leaves a build
running, and coming back reattaches with the scrollback. Two tabs share
one shell and the smaller window decides the size. It ends on an idle
timeout, on deleting the chat, on disabling, moving or deleting the
connection, and on a restart -- which says why rather than quietly
opening a fresh shell that has lost the working directory.

The nginx template's `Connection ""` is right for SSE and fails every
WebSocket handshake, so `location /` now uses a `map $http_upgrade`;
update.sh grows a drift check for it, because the only symptom on a
stale vhost is a panel that cannot connect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 01:44:07 +02:00

150 lines
5.3 KiB
Python
Executable File

#!/usr/bin/env python3
"""Download the pinned browser libraries into the static vendor directory.
LLeMbas has no Node toolchain and loads nothing from a CDN at runtime -- a
self-hosted tool should keep working without internet access, and should not
report every user's page view to a third party. The few libraries it does use
are fetched once, here, and committed.
Integrity is enforced with vendor.lock.json. A mismatched hash aborts rather
than overwriting, and so does a name that is not in the lock at all: that is
the whole point of pinning.
python scripts/fetch_vendor.py # fetch and verify against the lock
python scripts/fetch_vendor.py --update # re-pin after a version bump
"""
from __future__ import annotations
import argparse
import hashlib
import json
import sys
import urllib.error
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
VENDOR_DIR = ROOT / "src" / "lembas" / "web" / "static" / "vendor"
LOCKFILE = Path(__file__).resolve().parent / "vendor.lock.json"
# Pinned deliberately. Bump the version, run with --update, review the diff.
PACKAGES = {
"htmx.min.js": {
"version": "2.0.10",
"url": "https://unpkg.com/htmx.org@2.0.10/dist/htmx.min.js",
"why": "Server-rendered interactivity: every swap in the app.",
},
"htmx-ext-sse.js": {
"version": "2.2.4",
"url": "https://unpkg.com/htmx-ext-sse@2.2.4/sse.js",
"why": "Server-sent events, which is how streamed replies reach the page.",
},
"alpine.min.js": {
"version": "3.15.12",
"url": "https://unpkg.com/alpinejs@3.15.12/dist/cdn.min.js",
"why": "Small client-only state: menus, theme toggle, composer autosize.",
},
"xterm.js": {
"version": "5.5.0",
"url": "https://unpkg.com/@xterm/xterm@5.5.0/lib/xterm.js",
"why": "The terminal panel. Loaded only on a chat that has an SSH connection.",
},
"xterm.css": {
"version": "5.5.0",
"url": "https://unpkg.com/@xterm/xterm@5.5.0/css/xterm.css",
"why": "Terminal layout. Its colours are overridden from tokens.css at runtime.",
},
"xterm-addon-fit.js": {
"version": "0.10.0",
"url": "https://unpkg.com/@xterm/addon-fit@0.10.0/lib/addon-fit.js",
"why": "Sizes the terminal to the panel; without it a resize is 80x24 forever.",
},
}
def sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def fetch(url: str) -> bytes:
request = urllib.request.Request(url, headers={"User-Agent": "lembas-vendor-fetch"})
with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310
return response.read()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--update",
action="store_true",
help="rewrite vendor.lock.json with the hashes just downloaded",
)
args = parser.parse_args()
lock = json.loads(LOCKFILE.read_text()) if LOCKFILE.exists() else {}
VENDOR_DIR.mkdir(parents=True, exist_ok=True)
new_lock: dict[str, dict[str, str]] = {}
failed = False
for filename, spec in PACKAGES.items():
try:
payload = fetch(spec["url"])
except (urllib.error.URLError, TimeoutError) as exc:
print(f" FAIL {filename}: {exc}", file=sys.stderr)
failed = True
continue
digest = sha256(payload)
expected = lock.get(filename, {}).get("sha256")
if lock and not expected and not args.update:
# A name added to PACKAGES but absent from the lock has nothing to
# compare against, so the mismatch branch below never fires and the
# file lands unpinned -- which is the one thing this script exists
# to prevent. Adding a library is a --update, like bumping one.
print(
f" FAIL {filename}: not in {LOCKFILE.name}\n"
f" Nothing to verify this download against. If the "
f"library was added deliberately, re-run with --update.",
file=sys.stderr,
)
failed = True
continue
if expected and digest != expected and not args.update:
print(
f" FAIL {filename}: hash mismatch\n"
f" expected {expected}\n"
f" received {digest}\n"
f" Refusing to overwrite. If the version was bumped "
f"deliberately, re-run with --update.",
file=sys.stderr,
)
failed = True
continue
(VENDOR_DIR / filename).write_bytes(payload)
new_lock[filename] = {
"version": spec["version"],
"url": spec["url"],
"sha256": digest,
}
status = "ok" if expected == digest else ("pinned" if args.update else "new")
print(f" {status:>6} {filename} {len(payload):>8,} bytes v{spec['version']}")
if failed:
print("\nOne or more downloads failed. Vendored files were not fully written.")
return 1
if args.update or not LOCKFILE.exists():
LOCKFILE.write_text(json.dumps(new_lock, indent=2, sort_keys=True) + "\n")
print(f"\nwrote {LOCKFILE.relative_to(ROOT)}")
return 0
if __name__ == "__main__":
sys.exit(main())